diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index 2d45cb09e..bff6e5ae8 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -43,7 +43,7 @@ public class GBDaoGenerator { public static void main(String[] args) throws Exception { - final Schema schema = new Schema(43, MAIN_PACKAGE + ".entities"); + final Schema schema = new Schema(44, MAIN_PACKAGE + ".entities"); Entity userAttributes = addUserAttributes(schema); Entity user = addUserInfo(schema, userAttributes); @@ -638,6 +638,7 @@ public class GBDaoGenerator { 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); diff --git a/app/build.gradle b/app/build.gradle index 62e8fb689..e1a3af05b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -336,6 +336,17 @@ tasks.withType(SpotBugsTask) { } } +task cleanGenerated(type: Delete) { + delete fileTree('src/main/java/nodomain/freeyourgadget/gadgetbridge/proto') { + include '**/*.java' + } + delete fileTree('src/main/java/nodomain/freeyourgadget/gadgetbridge/entities') { + include '**/*.java' + exclude '**/Abstract*.java' + } +} + +tasks.clean.dependsOn(tasks.cleanGenerated) protobuf { protoc { @@ -344,7 +355,12 @@ protobuf { generateProtoTasks { all().each { task -> task.builtins { - java { option 'lite' } + java { + option 'lite' + // Uncomment this to get Android Studio to recognize the generated files + // this makes it think that all src files are generated though... + //outputSubDir = '../../../../../src/main/java/' + } } } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java index e4cd7c71d..b08f82d7d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java @@ -17,9 +17,11 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.activities; +import android.app.AlertDialog; 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; @@ -232,13 +234,25 @@ public class ActivitySummariesActivity extends AbstractListActivity toDelete = new ArrayList<>(); + final List toDelete = new ArrayList<>(); for (int i = 0; i < checked.size(); i++) { if (checked.valueAt(i)) { toDelete.add(getItemAdapter().getItem(checked.keyAt(i))); } } - deleteItems(toDelete); + + new AlertDialog.Builder(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; break; case R.id.activity_action_export: diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java index ee29b3a66..b8bc504c1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java @@ -73,6 +73,7 @@ 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.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; @@ -80,8 +81,10 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryItems; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryJsonSummary; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.SwipeEvents; @@ -414,13 +417,16 @@ public class ActivitySummaryDetail extends AbstractGBActivity { } private void makeSummaryContent(BaseActivitySummary item) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); + final ActivitySummaryParser summaryParser = coordinator.getActivitySummaryParser(gbDevice); + //make view of data from summaryData of item String units = GBApplication.getPrefs().getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, GBApplication.getContext().getString(R.string.p_unit_metric)); String UNIT_IMPERIAL = GBApplication.getContext().getString(R.string.p_unit_imperial); TableLayout fieldLayout = findViewById(R.id.summaryDetails); fieldLayout.removeAllViews(); //remove old widgets - ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(item); + ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(summaryParser, item); JSONObject data = activitySummaryJsonSummary.getSummaryGroupedList(); //get list, grouped by groups if (data == null) return; 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 index e95578d5f..7ad46a05b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java @@ -874,7 +874,11 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp if (supportedLanguages != null) { supportedSettings = ArrayUtils.insert(0, supportedSettings, R.xml.devicesettings_language_generic); } - supportedSettings = ArrayUtils.addAll(supportedSettings, coordinator.getSupportedDeviceSpecificAuthenticationSettings()); + final int[] supportedAuthSettings = coordinator.getSupportedDeviceSpecificAuthenticationSettings(); + if (supportedAuthSettings != null && supportedAuthSettings.length > 0) { + supportedSettings = ArrayUtils.add(supportedSettings, R.xml.devicesettings_header_authentication); + supportedSettings = ArrayUtils.addAll(supportedSettings, supportedAuthSettings); + } } final DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer = coordinator.getDeviceSpecificSettingsCustomizer(device); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ActivitySummariesAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ActivitySummariesAdapter.java index e21c2dd77..1f5353921 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ActivitySummariesAdapter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ActivitySummariesAdapter.java @@ -42,13 +42,16 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; 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.ActivitySummaryJsonSummary; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -184,6 +187,8 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter. */ +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/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index 1d2e09cf9..748f6c841 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -46,6 +46,7 @@ import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability; import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao; import nodomain.freeyourgadget.gadgetbridge.entities.BatteryLevelDao; @@ -54,6 +55,7 @@ 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.ActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; @@ -150,6 +152,12 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return device.isInitialized() && !device.isBusy() && supportsActivityDataFetching(); } + @Override + @Nullable + public ActivitySummaryParser getActivitySummaryParser(final GBDevice device) { + return null; + } + public boolean isHealthWearable(BluetoothDevice device) { BluetoothClass bluetoothClass = device.getBluetoothClass(); if (bluetoothClass == null) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index cfe26567e..6eee5375d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -40,6 +40,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; @@ -215,6 +216,13 @@ public interface DeviceCoordinator { */ SampleProvider getSampleProvider(GBDevice device, DaoSession session); + /** + * Returns the {@link ActivitySummaryParser} for the device being supported. + * + * @return + */ + ActivitySummaryParser getActivitySummaryParser(final GBDevice device); + /** * Returns true if this device/coordinator supports installing files like firmware, * watchfaces, gps, resources, fonts... diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021ActivitySummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021ActivitySummaryParser.java new file mode 100644 index 000000000..1d587cb31 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021ActivitySummaryParser.java @@ -0,0 +1,123 @@ +/* Copyright (C) 2022 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.devices.huami; + +import com.google.protobuf.InvalidProtocolBufferException; + +import org.apache.commons.lang3.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Date; + +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.Huami2021ActivityDetailsParser; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021WorkoutTrackActivityType; + +public class Huami2021ActivitySummaryParser extends HuamiActivitySummaryParser { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021ActivitySummaryParser.class); + + @Override + public AbstractHuamiActivityDetailsParser getDetailsParser(final BaseActivitySummary summary) { + return new Huami2021ActivityDetailsParser(summary); + } + + @Override + protected void parseBinaryData(final BaseActivitySummary summary, final Date startTime) { + final byte[] rawData = summary.getRawSummaryData(); + 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 Huami2021WorkoutTrackActivityType workoutTrackActivityType = Huami2021WorkoutTrackActivityType + .fromCode((byte) summaryProto.getType().getType()); + + final int activityKind; + if (workoutTrackActivityType != null) { + activityKind = workoutTrackActivityType.toActivityKind(); + } else { + LOG.warn("Unknown workout activity type code {}", String.format("0x%X", summaryProto.getType().getType())); + activityKind = ActivityKind.TYPE_UNKNOWN; + } + summary.setActivityKind(activityKind); + } + + if (summaryProto.hasTime()) { + int totalDuration = summaryProto.getTime().getTotalDuration(); + summary.setEndTime(new Date(startTime.getTime() + totalDuration * 1000L)); + addSummaryData("activeSeconds", summaryProto.getTime().getWorkoutDuration(), "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 + } + + if (summaryProto.hasHeartRate()) { + addSummaryData("averageHR", summaryProto.getHeartRate().getAvg(), "bpm"); + addSummaryData("maxHR", summaryProto.getHeartRate().getMax(), "bpm"); + addSummaryData("minHR", summaryProto.getHeartRate().getMin(), "bpm"); + } + + if (summaryProto.hasSteps()) { + addSummaryData("maxCadence", summaryProto.getSteps().getMaxCadence() * 60, "spm"); + addSummaryData("averageCadence", summaryProto.getSteps().getAvgCadence() * 60, "spm"); + addSummaryData("averageStride", summaryProto.getSteps().getAvgStride(), "cm"); + addSummaryData("steps", summaryProto.getSteps().getSteps(), "steps_unit"); + } + + if (summaryProto.hasDistance()) { + addSummaryData("distanceMeters", summaryProto.getDistance().getDistance(), "meters"); + } + + if (summaryProto.hasPace()) { + addSummaryData("maxPace", summaryProto.getPace().getBest(), "seconds_m"); + addSummaryData("averageKMPaceSeconds", summaryProto.getPace().getAvg() * 1000, "seconds_km"); + } + + if (summaryProto.hasCalories()) { + addSummaryData("caloriesBurnt", summaryProto.getCalories().getCalories(), "calories_unit"); + } + + if (summaryProto.hasHeartRateZones()) { + // TODO HR zones + } + + if (summaryProto.hasTrainingEffect()) { + // TODO training effect + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java index 80cb6f9de..ef7954f07 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java @@ -33,6 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.HuamiExtendedActivitySampleDao; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; public abstract class Huami2021Coordinator extends HuamiCoordinator { @Override @@ -68,8 +69,7 @@ public abstract class Huami2021Coordinator extends HuamiCoordinator { @Override public boolean supportsActivityTracks() { - // TODO: It's supported by the devices, but not yet implemented - return false; + return true; } @Override @@ -102,6 +102,11 @@ public abstract class Huami2021Coordinator extends HuamiCoordinator { return new HuamiExtendedSampleProvider(device, session); } + @Override + public ActivitySummaryParser getActivitySummaryParser(final GBDevice device) { + return new Huami2021ActivitySummaryParser(); + } + @Override public boolean supportsAlarmSnoozing() { // All alarms snooze by default, there doesn't seem to be a flag that disables it @@ -194,6 +199,9 @@ public abstract class Huami2021Coordinator extends HuamiCoordinator { R.xml.devicesettings_expose_hr_thirdparty, R.xml.devicesettings_bt_connected_advertisement, R.xml.devicesettings_high_mtu, + + R.xml.devicesettings_header_developer, + R.xml.devicesettings_keep_activity_data_on_device, }; } 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 index 8d3946293..ea55d2c1e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiActivitySummaryParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiActivitySummaryParser.java @@ -30,6 +30,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; 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; @@ -45,11 +46,17 @@ public class HuamiActivitySummaryParser implements ActivitySummaryParser { LOG.error("Due to a bug, we can only parse the summary when startTime is already set"); return null; } - return parseBinaryData(summary, startTime); + summaryData = new JSONObject(); + parseBinaryData(summary, startTime); + summary.setSummaryData(summaryData.toString()); + return summary; } - private BaseActivitySummary parseBinaryData(BaseActivitySummary summary, Date startTime) { - summaryData = new JSONObject(); + public AbstractHuamiActivityDetailsParser getDetailsParser(final BaseActivitySummary summary) { + return new HuamiActivityDetailsParser(summary); + } + + protected void parseBinaryData(BaseActivitySummary summary, Date startTime) { ByteBuffer buffer = ByteBuffer.wrap(summary.getRawSummaryData()).order(ByteOrder.LITTLE_ENDIAN); short version = buffer.getShort(); // version @@ -372,13 +379,9 @@ public class HuamiActivitySummaryParser implements ActivitySummaryParser { addSummaryData("swimStyle", swimStyleName); addSummaryData("laps", laps, "laps"); } - - summary.setSummaryData(summaryData.toString()); - return summary; } - - private void addSummaryData(String key, float value, String unit) { + protected void addSummaryData(String key, float value, String unit) { if (value > 0) { try { JSONObject innerData = new JSONObject(); @@ -390,7 +393,7 @@ public class HuamiActivitySummaryParser implements ActivitySummaryParser { } } - private void addSummaryData(String key, String value) { + protected void addSummaryData(String key, String value) { if (key != null && !key.equals("") && value != null && !value.equals("")) { try { JSONObject innerData = new JSONObject(); 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 index 311737ac0..cd531deef 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java @@ -59,6 +59,7 @@ 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; @@ -140,6 +141,11 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { return new MiBand2SampleProvider(device, session); } + @Override + public ActivitySummaryParser getActivitySummaryParser(final GBDevice device) { + return new HuamiActivitySummaryParser(); + } + public static DateTimeDisplay getDateDisplay(Context context, String deviceAddress) throws IllegalArgumentException { SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress); String dateFormatTime = context.getString(R.string.p_dateformat_time); @@ -352,6 +358,11 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { 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); + } + public static VibrationProfile getVibrationProfile(String deviceAddress, HuamiVibrationPatternNotificationType notificationType) { final String defaultVibrationProfileId; final int defaultVibrationCount; 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 index 669ea22f4..83ad746dd 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java @@ -115,8 +115,9 @@ public class HuamiService { // maybe not really activity data, but steps? public static final byte COMMAND_FETCH_DATA = 0x02; - // maybe delete/drop activity data? - // on Huami it's just 03 / on Huami 2021 it's 03:09 + // 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 }; @@ -230,13 +231,6 @@ public class HuamiService { 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[] RESPONSE_FINISH_SUCCESS = new byte[] {RESPONSE, 2, SUCCESS }; - public static final byte[] RESPONSE_ACK_SUCCESS = new byte[] {RESPONSE, 3, SUCCESS }; - public static final byte[] RESPONSE_FIRMWARE_DATA_SUCCESS = new byte[] {RESPONSE, COMMAND_FIRMWARE_START_DATA, SUCCESS }; - /** - * Received in response to any dateformat configuration request (byte 0 in the byte[] value. - */ - public static final byte[] RESPONSE_DATEFORMAT_SUCCESS = new byte[] { RESPONSE, ENDPOINT_DISPLAY, 0x0a, 0x0, 0x01 }; public static final byte[] RESPONSE_ACTIVITY_DATA_START_DATE_SUCCESS = new byte[] { RESPONSE, COMMAND_ACTIVITY_DATA_START_DATE, SUCCESS}; public static final byte[] WEAR_LOCATION_LEFT_WRIST = new byte[] { 0x20, 0x00, 0x00, 0x02 }; 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 index 32ccc9485..7a407adf6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSSupport.java @@ -33,7 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSuppo 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.SMAQ2OSSProtos; +import nodomain.freeyourgadget.gadgetbridge.proto.SMAQ2OSSProtos; import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummary.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummary.java index 897696cc6..f069abd00 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummary.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummary.java @@ -16,14 +16,9 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.model; -import org.json.JSONObject; - import java.io.Serializable; import java.util.Date; -import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiActivitySummaryParser; -import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; - /** * Summarized information about a temporal activity. * diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryJsonSummary.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryJsonSummary.java index cd10334e9..535ef81e3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryJsonSummary.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryJsonSummary.java @@ -16,9 +16,11 @@ public class ActivitySummaryJsonSummary { private JSONObject groupData; private JSONObject summaryData; private JSONObject summaryGroupedList; + private ActivitySummaryParser summaryParser; private BaseActivitySummary baseActivitySummary; - public ActivitySummaryJsonSummary(BaseActivitySummary baseActivitySummary){ + public ActivitySummaryJsonSummary(final ActivitySummaryParser summaryParser, BaseActivitySummary baseActivitySummary){ + this.summaryParser=summaryParser; this.baseActivitySummary=baseActivitySummary; } @@ -67,8 +69,7 @@ public class ActivitySummaryJsonSummary { private String getCorrectSummary(BaseActivitySummary item){ if (item.getRawSummaryData() != null) { - ActivitySummaryParser parser = new HuamiActivitySummaryParser(); // FIXME: if something else than huami supports that make sure to have the right parser - item = parser.parseBinaryData(item); + item = summaryParser.parseBinaryData(item); } return item.getSummaryData(); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/proto/.gitignore b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/proto/.gitignore new file mode 100644 index 000000000..af230b12f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/proto/.gitignore @@ -0,0 +1,2 @@ +# This folder will contain auto-generated protobuf classes +*.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java index fde1dae34..7ac0c10cc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java @@ -149,6 +149,10 @@ public class BLETypeConversions { return (bytes[0] & 0xff) | ((bytes[1] & 0xff) << 8); } + public static int toUint16(byte[] bytes, int offset) { + return (bytes[offset + 0] & 0xff) | ((bytes[offset + 1] & 0xff) << 8); + } + public static int toInt16(byte... bytes) { return (short) (bytes[0] & 0xff | ((bytes[1] & 0xff) << 8)); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiActivityDetailsParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiActivityDetailsParser.java new file mode 100644 index 000000000..0514d15e3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiActivityDetailsParser.java @@ -0,0 +1,48 @@ +/* Copyright (C) 2017-2021 Andreas Shimokawa, AndrewH, Carsten Pfeiffer, + szilardx, José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.huami; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; + +public abstract class AbstractHuamiActivityDetailsParser { + private static final BigDecimal HUAMI_TO_DECIMAL_DEGREES_DIVISOR = new BigDecimal("3000000.0"); + + public abstract ActivityTrack parse(final byte[] bytes) throws GBException; + + public static double convertHuamiValueToDecimalDegrees(final long huamiValue) { + BigDecimal result = new BigDecimal(huamiValue) + .divide(HUAMI_TO_DECIMAL_DEGREES_DIVISOR, GPSCoordinate.GPS_DECIMAL_DEGREES_SCALE, RoundingMode.HALF_UP); + return result.doubleValue(); + } + + protected static String createActivityName(final BaseActivitySummary summary) { + String name = summary.getName(); + String nameText = ""; + Long id = summary.getId(); + if (name != null) { + nameText = name + " - "; + } + return nameText + id; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ActivityDetailsParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ActivityDetailsParser.java new file mode 100644 index 000000000..e899fbd17 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ActivityDetailsParser.java @@ -0,0 +1,351 @@ +/* Copyright (C) 2022 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.huami; + +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; + +public class Huami2021ActivityDetailsParser extends AbstractHuamiActivityDetailsParser { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021ActivityDetailsParser.class); + + private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US); + + static { + SDF.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + private Date timestamp; + private long offset = 0; + + private long longitude; + private long latitude; + private double altitude; + + private final ActivityTrack activityTrack; + private ActivityPoint lastActivityPoint; + + public Huami2021ActivityDetailsParser(final BaseActivitySummary summary) { + this.timestamp = summary.getStartTime(); + + this.longitude = summary.getBaseLongitude(); + this.latitude = summary.getBaseLatitude(); + this.altitude = summary.getBaseAltitude(); + + this.activityTrack = new ActivityTrack(); + this.activityTrack.setUser(summary.getUser()); + this.activityTrack.setDevice(summary.getDevice()); + this.activityTrack.setName(createActivityName(summary)); + } + + @Override + public ActivityTrack parse(final byte[] bytes) throws GBException { + final ByteBuffer buf = ByteBuffer.wrap(bytes) + .order(ByteOrder.LITTLE_ENDIAN); + + // Keep track of unknown type codes so we can print them without spamming the logs + final Map unknownTypeCodes = new HashMap<>(); + + while (buf.position() < buf.limit()) { + final byte typeCode = buf.get(); + final byte length = buf.get(); + final int initialPosition = buf.position(); + + final Type type = Type.fromCode(typeCode); + if (type == null) { + if (!unknownTypeCodes.containsKey(typeCode)) { + unknownTypeCodes.put(typeCode, 0); + } + + unknownTypeCodes.put(typeCode, unknownTypeCodes.get(typeCode) + 1); + //LOG.warn("Unknown type code {} of length {}", String.format("0x%X", typeCode), length); + // Consume the reported length + buf.get(new byte[length]); + continue; + } else if (length != type.getExpectedLength()) { + LOG.warn("Unexpected length {} for type {}", length, type); + // Consume the reported length + buf.get(new byte[length]); + continue; + } + + // Consume + switch (type) { + case TIMESTAMP: + consumeTimestamp(buf); + break; + case GPS_COORDS: + consumeGpsCoords(buf); + break; + case GPS_DELTA: + consumeGpsDelta(buf); + break; + case STATUS: + consumeStatus(buf); + break; + case SPEED: + consumeSpeed(buf); + break; + case ALTITUDE: + consumeAltitude(buf); + break; + case HEARTRATE: + consumeHeartRate(buf); + break; + default: + LOG.warn("No consumer for for type {}", type); + // Consume the reported length + buf.get(new byte[length]); + continue; + } + + final int expectedPosition = initialPosition + length; + if (buf.position() != expectedPosition) { + // Should never happen unless there's a bug in one of the consumers + throw new IllegalStateException("Unexpected position " + buf.position() + ", expected " + expectedPosition + ", after consuming " + type); + } + } + + if (!unknownTypeCodes.isEmpty()) { + for (final Map.Entry e : unknownTypeCodes.entrySet()) { + LOG.warn("Unknown type code {} seen {} times", String.format("0x%X", e.getKey()), e.getValue()); + } + } + + return this.activityTrack; + } + + private void consumeTimestamp(final ByteBuffer buf) { + buf.getInt(); // ? + this.timestamp = new Date(buf.getLong()); + this.offset = 0; + + //trace("Consumed timestamp"); + } + + private void consumeTimestampOffset(final ByteBuffer buf) { + this.offset = buf.getShort(); + } + + private void consumeGpsCoords(final ByteBuffer buf) { + buf.get(new byte[6]); // ? + this.longitude = buf.getInt(); + this.latitude = buf.getInt(); + buf.get(new byte[6]); // ? + + // TODO which one is the time offset? Not sure it is the first + + addNewGpsCoordinates(); + + final double longitudeDeg = convertHuamiValueToDecimalDegrees(longitude); + final double latitudeDeg = convertHuamiValueToDecimalDegrees(latitude); + + //trace("Consumed GPS coords: {} {}", longitudeDeg, latitudeDeg); + } + + private void consumeGpsDelta(final ByteBuffer buf) { + consumeTimestampOffset(buf); + final short longitudeDelta = buf.getShort(); + final short latitudeDelta = buf.getShort(); + buf.getShort(); // ? seems to always be 2 + + this.longitude += longitudeDelta; + this.latitude += latitudeDelta; + + if (lastActivityPoint == null) { + final String timestampStr = SDF.format(new Date(timestamp.getTime() + offset)); + LOG.warn("{}: Got GPS delta before GPS coords, ignoring", timestampStr); + return; + } + + addNewGpsCoordinates(); + + //trace("Consumed GPS delta: {} {}", longitudeDelta, latitudeDelta); + } + + private void consumeStatus(final ByteBuffer buf) { + consumeTimestampOffset(buf); + + final int statusCode = buf.getShort(); + final String status; + switch (statusCode) { + case 1: + status = "start"; + break; + case 4: + status = "pause"; + break; + case 5: + status = "resume"; + break; + case 6: + status = "stop"; + break; + default: + status = String.format("unknown (0x%X)", statusCode); + LOG.warn("Unknown status code {}", String.format("0x%X", statusCode)); + } + + // TODO split track into multiple segments? + + //trace("Consumed Status: {}", status); + } + + private void consumeSpeed(final ByteBuffer buf) { + consumeTimestampOffset(buf); + + final short cadence = buf.getShort(); // spm + final short stride = buf.getShort(); // cm + final short pace = buf.getShort(); // sec/km + + // TODO integrate into gpx + + //trace("Consumed speed: cadence={}, stride={}, ?={}", cadence, stride, ); + } + + private void consumeAltitude(final ByteBuffer buf) { + consumeTimestampOffset(buf); + altitude = (int) (buf.getInt() / 100.0f); + + final ActivityPoint ap = getCurrentActivityPoint(); + if (ap != null) { + final GPSCoordinate newCoordinate = new GPSCoordinate( + ap.getLocation().getLongitude(), + ap.getLocation().getLatitude(), + altitude + ); + + ap.setLocation(newCoordinate); + } + + //trace("Consumed altitude: {}", altitude); + } + + private void consumeHeartRate(final ByteBuffer buf) { + consumeTimestampOffset(buf); + final int heartRate = buf.get() & 0xff; + + final ActivityPoint ap = getCurrentActivityPoint(); + if (ap != null) { + ap.setHeartRate(heartRate); + } + + //trace("Consumed HeartRate: {}", heartRate); + } + + @Nullable + private ActivityPoint getCurrentActivityPoint() { + if (lastActivityPoint == null) { + return null; + } + + // Round to the nearest second + final long currentTime = timestamp.getTime() + offset; + if (currentTime - lastActivityPoint.getTime().getTime() > 500) { + addNewGpsCoordinates(); + return lastActivityPoint; + } + + return lastActivityPoint; + } + + private void addNewGpsCoordinates() { + final GPSCoordinate coordinate = new GPSCoordinate( + convertHuamiValueToDecimalDegrees(longitude), + convertHuamiValueToDecimalDegrees(latitude), + altitude + ); + + if (lastActivityPoint != null && lastActivityPoint.getLocation() != null && lastActivityPoint.getLocation().equals(coordinate)) { + // Ignore repeated location + return; + } + + final ActivityPoint ap = new ActivityPoint(new Date(timestamp.getTime() + offset)); + ap.setLocation(coordinate); + add(ap); + } + + private void add(final ActivityPoint ap) { + if (ap == lastActivityPoint) { + LOG.debug("skipping point!"); + return; + } + + lastActivityPoint = ap; + activityTrack.addTrackPoint(ap); + } + + private void trace(final String format, final Object... args) { + final Object[] argsWithDate = ArrayUtils.insert(0, args, SDF.format(new Date(timestamp.getTime() + offset))); + LOG.debug("{}: " + format, argsWithDate); + } + + private enum Type { + TIMESTAMP(1, 12), + GPS_COORDS(2, 20), + GPS_DELTA(3, 8), + STATUS(4, 4), + SPEED(5, 8), + ALTITUDE(7, 6), + HEARTRATE(8, 3), + ; + + private final byte code; + private final int expectedLength; + + Type(final int code, final int expectedLength) { + this.code = (byte) code; + this.expectedLength = expectedLength; + } + + public byte getCode() { + return this.code; + } + + public int getExpectedLength() { + return this.expectedLength; + } + + public static Type fromCode(final byte code) { + for (final Type type : values()) { + if (type.getCode() == code) { + return type; + } + } + + return null; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021WorkoutTrackActivityType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021WorkoutTrackActivityType.java index c584af07b..cdd71a1eb 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021WorkoutTrackActivityType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021WorkoutTrackActivityType.java @@ -25,22 +25,123 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; * The workout types, used to start / when workout tracking starts on the band. */ public enum Huami2021WorkoutTrackActivityType { - // TODO 150 workouts :/ + AerobicCombo(0x33), + Aerobics(0x6d), + AirWalker(0x90), + Archery(0x5d), + ArtisticSwimming(0x9c), Badminton(0x5c), + Ballet(0x47), + BallroomDance(0x4b), + Baseball(0x4f), + Basketball(0x55), + BattleRope(0xa7), + BeachVolleyball(0x7a), + BellyDance(0x48), + Billiards(0x97), + bmx(0x30), + BoardGame(0xb1), + Bocce(0xaa), + Bowling(0x50), + Boxing(0x61), + Breaking(0xa8), + Bridge(0xb0), + CardioCombat(0x72), + Checkers(0xae), + Chess(0xad), + CoreTraining(0x32), + Cricket(0x4e), + CrossTraining(0x82), + Curling(0x29), Dance(0x4c), + Darts(0x75), + Dodgeball(0x99), + DragonBoat(0x8a), Elliptical(0x09), + Esports(0xbd), + Esquestrian(0x5e), + Fencing(0x94), + Finswimming(0x9b), + Fishing(0x40), + Flexibility(0x37), + Flowriding(0xac), + FolkDance(0x92), Freestyle(0x05), + Frisbee(0x74), + Futsal(0xa4), + Gateball(0x57), + Gymnastics(0x3b), + HackySack(0xa9), + Handball(0x5b), + HIIT(0x31), + HipHop(0xa5), + HorizontalBar(0x95), + HulaHoop(0x73), + IceHockey(0x9e), + IceSkating(0x2c), IndoorCycling(0x08), IndoorFitness(0x18), + IndoorIceSkating(0x2d), + JaiAlai(0xab), + JazzDance(0x71), + Judo(0x62), + Jujitsu(0x93), JumpRope(0x15), + Karate(0x60), + Kayaking(0x8c), + Kendo(0x5f), + Kickboxing(0x68), + KiteFlying(0x76), + LatinDance(0x70), + MartialArts(0x67), + MassGymnastics(0x6f), + ModernDance(0xb9), + MuayThai(0x65), OutdoorCycling(0x04), OutdoorRunning(0x01), + ParallelBars(0x96), + Parkour(0x81), + Pilates(0x3d), + PoleDance(0xa6), PoolSwimming(0x06), + RaceWalking(0x83), + RockClimbing(0x46), + RollerSkating(0x45), Rowing(0x17), + Sailing(0x41), + SepakTakraw(0x98), + Shuffleboard(0xa0), + Shuttlecock(0xa2), + Skateboarding(0x43), + Snorkeling(0x9d), Soccer(0xbf), + Softball(0x56), + SomatosensoryGame(0xa3), + Spinning(0x8f), + SquareDance(0x49), + Squash(0x51), + StairClimber(0x36), + Stepper(0x39), + StreetDance(0x4a), + Strength(0x34), + Stretching(0x35), + Swinging(0x9f), + TableFootball(0xa1), + TableTennis(0x59), + TaiChi(0x64), + Taekwondo(0x66), + Tennis(0x11), Treadmill(0x02), + TugOfWar(0x77), + Volleyball(0x58), Walking(0x03), + WallBall(0x91), + WaterPolo(0x9a), + WaterRowing(0x42), + Weiqi(0xaf), + Wrestling(0x63), Yoga(0x3c), + Zumba(0x4d), ; private static final Logger LOG = LoggerFactory.getLogger(Huami2021WorkoutTrackActivityType.class); @@ -61,6 +162,9 @@ public enum Huami2021WorkoutTrackActivityType { return ActivityKind.TYPE_BADMINTON; case Elliptical: return ActivityKind.TYPE_ELLIPTICAL_TRAINER; + case Freestyle: + case IndoorFitness: + return ActivityKind.TYPE_EXERCISE; case IndoorCycling: return ActivityKind.TYPE_INDOOR_CYCLING; case JumpRope: @@ -78,6 +182,7 @@ public enum Huami2021WorkoutTrackActivityType { case Treadmill: return ActivityKind.TYPE_TREADMILL; case Walking: + case RaceWalking: return ActivityKind.TYPE_WALKING; case Yoga: return ActivityKind.TYPE_YOGA; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiActivityDetailsParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiActivityDetailsParser.java index af206c294..d7d3ae11e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiActivityDetailsParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiActivityDetailsParser.java @@ -35,7 +35,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.util.GB; -public class HuamiActivityDetailsParser { +public class HuamiActivityDetailsParser extends AbstractHuamiActivityDetailsParser { private static final Logger LOG = LoggerFactory.getLogger(HuamiActivityDetailsParser.class); private static final byte TYPE_GPS = 0; @@ -47,7 +47,6 @@ public class HuamiActivityDetailsParser { private static final byte TYPE_SPEED6 = 6; private static final byte TYPE_SWIMMING = 8; - private static final BigDecimal HUAMI_TO_DECIMAL_DEGREES_DIVISOR = new BigDecimal(3000000.0); private final ActivityTrack activityTrack; private final Date baseDate; private long baseLongitude; @@ -195,11 +194,6 @@ public class HuamiActivityDetailsParser { return i; } - private double convertHuamiValueToDecimalDegrees(long huamiValue) { - BigDecimal result = new BigDecimal(huamiValue).divide(HUAMI_TO_DECIMAL_DEGREES_DIVISOR, GPSCoordinate.GPS_DECIMAL_DEGREES_SCALE, RoundingMode.HALF_UP); - return result.doubleValue(); - } - private int consumeHeartRate(byte[] bytes, int offset, long timeOffsetSeconds) { int v1 = BLETypeConversions.toUint16(bytes[offset]); int v2 = BLETypeConversions.toUint16(bytes[offset + 1]); @@ -295,14 +289,4 @@ public class HuamiActivityDetailsParser { LOG.debug("got packet type 8 (swimming?): " + GB.hexdump(bytes, offset, 6)); return 6; } - - private String createActivityName(BaseActivitySummary summary) { - String name = summary.getName(); - String nameText = ""; - Long id = summary.getId(); - if (name != null) { - nameText = name + " - "; - } - return nameText + id; - } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java index 9ffef604b..18b2e1379 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java @@ -39,6 +39,7 @@ import java.util.concurrent.TimeUnit; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.Logging; import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; @@ -84,6 +85,7 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation { } protected void startFetching() throws IOException { + expectedDataLength = 0; lastPacketCounter = -1; TransactionBuilder builder = performInitialized(getName()); @@ -122,13 +124,28 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation { } } + /** + * Handles the finishing of fetching the activity. + * @param success whether fetching was successful + * @return whether handling the activity fetch finish was successful + */ @CallSuper - protected void handleActivityFetchFinish(boolean success) { + protected boolean handleActivityFetchFinish(boolean success) { GB.updateTransferNotification(null, "", false, 100, getContext()); operationFinished(); unsetBusy(); + return true; } + /** + * Validates that the received data has the expected checksum. Only + * relevant for Huami2021Support devices. + * + * @param crc32 the expected checksum + * @return whether the checksum was valid + */ + protected abstract boolean validChecksum(int crc32); + /** * Method to handle the incoming activity data. * There are two kind of messages we currently know: @@ -158,13 +175,18 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation { if (ArrayUtils.equals(value, HuamiService.RESPONSE_ACTIVITY_DATA_START_DATE_SUCCESS, 0)) { handleActivityMetadata(value); - TransactionBuilder newBuilder = createTransactionBuilder(taskName + " Step 2"); - newBuilder.notify(characteristicActivityData, true); - newBuilder.write(characteristicFetch, new byte[]{HuamiService.COMMAND_FETCH_DATA}); - try { - performImmediately(newBuilder); - } catch (IOException ex) { - GB.toast(getContext(), "Error fetching debug logs: " + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex); + if (expectedDataLength == 0 && getSupport() instanceof Huami2021Support) { + // Nothing to receive, if we try to fetch data it will fail + sendAck2021(true); + } else { + TransactionBuilder newBuilder = createTransactionBuilder(taskName + " Step 2"); + newBuilder.notify(characteristicActivityData, true); + newBuilder.write(characteristicFetch, new byte[]{HuamiService.COMMAND_FETCH_DATA}); + try { + performImmediately(newBuilder); + } catch (IOException ex) { + GB.toast(getContext(), "Error fetching debug logs: " + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex); + } } return true; } else { @@ -177,54 +199,119 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation { } private void handleActivityMetadata(byte[] value) { - // it's 16 on the MB7, with a 0 at the end - if (value.length == 15 || (value.length == 16 && value[15] == 0x00)) { - // first two bytes are whether our request was accepted - if (ArrayUtils.equals(value, HuamiService.RESPONSE_ACTIVITY_DATA_START_DATE_SUCCESS, 0)) { - // the third byte (0x01 on success) = ? - // the 4th - 7th bytes represent the number of bytes/packets to expect, excluding the counter bytes - expectedDataLength = BLETypeConversions.toUint32(Arrays.copyOfRange(value, 3, 7)); + if (value.length < 3) { + LOG.warn("Activity metadata too short: {}", Logging.formatBytes(value)); + handleActivityFetchFinish(false); + return; + } - // last 8 bytes are the start date - Calendar startTimestamp = getSupport().fromTimeBytes(Arrays.copyOfRange(value, 7, value.length)); - setStartTimestamp(startTimestamp); + if (value[0] != HuamiService.RESPONSE) { + LOG.warn("Activity metadata not a response: {}", Logging.formatBytes(value)); + handleActivityFetchFinish(false); + return; + } - LOG.info("Will transfer {} packets since {}", expectedDataLength, startTimestamp.getTime()); - - GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), - getContext().getString(R.string.FetchActivityOperation_about_to_transfer_since, - DateFormat.getDateTimeInstance().format(startTimestamp.getTime())), true, 0, getContext()); - } else { + switch (value[1]) { + case HuamiService.COMMAND_ACTIVITY_DATA_START_DATE: + handleStartDateResponse(value); + return; + case HuamiService.COMMAND_FETCH_DATA: + handleFetchDataResponse(value); + return; + case HuamiService.COMMAND_ACK_ACTIVITY_DATA: + // ignore, this is just the reply to the COMMAND_ACK_ACTIVITY_DATA + LOG.info("Got reply to COMMAND_ACK_ACTIVITY_DATA"); + return; + default: LOG.warn("Unexpected activity metadata: {}", Logging.formatBytes(value)); handleActivityFetchFinish(false); - } - } else if (ArrayUtils.startsWith(value, HuamiService.RESPONSE_FINISH_SUCCESS)) { - if (value.length == 3) { - // older Huami devices, just finish - handleActivityFetchFinish(true); - } else if (value.length == 7 && getSupport() instanceof Huami2021Support) { - // TODO: What do the extra 4 bytes mean? - try { - // not sure why we need to send this (it's acknowledging the data?) but it will get stuck otherwise - final TransactionBuilder builder = performInitialized(getName() + " end"); - builder.write(characteristicFetch, new byte[]{HuamiService.COMMAND_ACK_ACTIVITY_DATA, 0x09}); - builder.queue(getQueue()); - } catch (final IOException e) { - LOG.error("Ending failed", e); - handleActivityFetchFinish(false); - return; - } - handleActivityFetchFinish(true); - } else { - LOG.warn("Unexpected activity metadata finish success: {}", Logging.formatBytes(value)); - handleActivityFetchFinish(false); - } - } else if (Arrays.equals(HuamiService.RESPONSE_ACK_SUCCESS, value) && getSupport() instanceof Huami2021Support) { - // ignore, this is just the reply to the COMMAND_ACK_ACTIVITY_DATA - LOG.info("Got reply to COMMAND_ACK_ACTIVITY_DATA"); - } else { - LOG.warn("Unexpected activity metadata: {}", Logging.formatBytes(value)); + } + } + + private void handleStartDateResponse(final byte[] value) { + if (value[2] != HuamiService.SUCCESS) { + LOG.warn("Start date unsuccessful response: {}", Logging.formatBytes(value)); handleActivityFetchFinish(false); + return; + } + + // it's 16 on the MB7, with a 0 at the end + if (value.length != 15 && (value.length != 16 && value[15] != 0x00)) { + LOG.warn("Start date response length: {}", Logging.formatBytes(value)); + handleActivityFetchFinish(false); + return; + } + + // the third byte (0x01 on success) = ? + // the 4th - 7th bytes represent the number of bytes/packets to expect, excluding the counter bytes + expectedDataLength = BLETypeConversions.toUint32(Arrays.copyOfRange(value, 3, 7)); + + // last 8 bytes are the start date + Calendar startTimestamp = getSupport().fromTimeBytes(Arrays.copyOfRange(value, 7, value.length)); + + if (expectedDataLength == 0) { + LOG.info("No data to fetch since {}", startTimestamp.getTime()); + handleActivityFetchFinish(true); + return; + } + + setStartTimestamp(startTimestamp); + LOG.info("Will transfer {} packets since {}", expectedDataLength, startTimestamp.getTime()); + + GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), + getContext().getString(R.string.FetchActivityOperation_about_to_transfer_since, + DateFormat.getDateTimeInstance().format(startTimestamp.getTime())), true, 0, getContext()); + } + + private void handleFetchDataResponse(final byte[] value) { + if (value[2] != HuamiService.SUCCESS) { + LOG.warn("Fetch data unsuccessful response: {}", Logging.formatBytes(value)); + handleActivityFetchFinish(false); + return; + } + + if (value.length != 3 && value.length != 7) { + LOG.warn("Fetch data unexpected metadata length: {}", Logging.formatBytes(value)); + handleActivityFetchFinish(false); + return; + } + + if (value.length == 7 && !validChecksum(BLETypeConversions.toUint32(value, 3))) { + LOG.warn("Data checksum invalid"); + handleActivityFetchFinish(false); + sendAck2021(true); + return; + } + + boolean handleFinishSuccess; + try { + handleFinishSuccess = handleActivityFetchFinish(true); + } catch (final Exception e) { + LOG.warn("Failed to handle activity fetch finish", e); + handleFinishSuccess = false; + } + + final boolean keepActivityDataOnDevice = HuamiCoordinator.getKeepActivityDataOnDevice(getDevice().getAddress()); + + sendAck2021(keepActivityDataOnDevice || !handleFinishSuccess); + } + + private void sendAck2021(final boolean keepDataOnDevice) { + if (!(getSupport() instanceof Huami2021Support)) { + return; + } + + // 0x01 to ACK, mark as saved on phone (drop from band) + // 0x09 to ACK, but keep it marked as not saved + // If 0x01 is sent, detailed information seems to be discarded, and is not sent again anymore + final byte ackByte = (byte) (keepDataOnDevice ? 0x09 : 0x01); + + try { + final TransactionBuilder builder = performInitialized(getName() + " end"); + builder.write(characteristicFetch, new byte[]{HuamiService.COMMAND_ACK_ACTIVITY_DATA, ackByte}); + performImmediately(builder); + } catch (final IOException e) { + LOG.error("Ending failed", e); } } @@ -242,7 +329,6 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation { editor.apply(); } - protected GregorianCalendar getLastSuccessfulSyncTime() { long timeStampMillis = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()).getLong(getLastSyncTimeKey(), 0); if (timeStampMillis != 0) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchActivityOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchActivityOperation.java index 5436676d4..981821936 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchActivityOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchActivityOperation.java @@ -45,6 +45,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -78,21 +79,31 @@ public class FetchActivityOperation extends AbstractFetchOperation { startFetching(builder, HuamiService.COMMAND_ACTIVITY_DATA_TYPE_ACTIVTY, sinceWhen); } - protected void handleActivityFetchFinish(boolean success) { + @Override + protected boolean handleActivityFetchFinish(boolean success) { LOG.info("{} has finished round {}", getName(), fetchCount); GregorianCalendar lastSyncTimestamp = saveSamples(); if (lastSyncTimestamp != null && needsAnotherFetch(lastSyncTimestamp)) { try { startFetching(); - return; + return true; } catch (IOException ex) { LOG.error("Error starting another round of {}", getName(), ex); + return false; } } - super.handleActivityFetchFinish(success); + final boolean superSuccess = super.handleActivityFetchFinish(success); GB.signalActivityDataFinish(); + return superSuccess; + } + + @Override + protected boolean validChecksum(int crc32) { + // TODO actually check it + LOG.warn("Checksum not implemented for activity data, assuming it's valid"); + return true; } private boolean needsAnotherFetch(GregorianCalendar lastSyncTimestamp) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsDetailsOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsDetailsOperation.java index 3e5313c5b..c681cde8f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsDetailsOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsDetailsOperation.java @@ -24,6 +24,8 @@ import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; import java.util.GregorianCalendar; import androidx.annotation.NonNull; @@ -39,8 +41,10 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiActivityDetailsParser; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -51,15 +55,20 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB; */ public class FetchSportsDetailsOperation extends AbstractFetchOperation { private static final Logger LOG = LoggerFactory.getLogger(FetchSportsDetailsOperation.class); + private final AbstractHuamiActivityDetailsParser detailsParser; private final BaseActivitySummary summary; private final String lastSyncTimeKey; private ByteArrayOutputStream buffer; - FetchSportsDetailsOperation(@NonNull BaseActivitySummary summary, @NonNull HuamiSupport support, @NonNull String lastSyncTimeKey) { + FetchSportsDetailsOperation(@NonNull BaseActivitySummary summary, + @NonNull AbstractHuamiActivityDetailsParser detailsParser, + @NonNull HuamiSupport support, + @NonNull String lastSyncTimeKey) { super(support); setName("fetching sport details"); this.summary = summary; + this.detailsParser = detailsParser; this.lastSyncTimeKey = lastSyncTimeKey; } @@ -72,7 +81,7 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation { } @Override - protected void handleActivityFetchFinish(boolean success) { + protected boolean handleActivityFetchFinish(boolean success) { LOG.info(getName() + " has finished round " + fetchCount); // GregorianCalendar lastSyncTimestamp = saveSamples(); // if (lastSyncTimestamp != null && needsAnotherFetch(lastSyncTimestamp)) { @@ -84,12 +93,14 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation { // } // } + boolean parseSuccess = true; - if (success) { - HuamiActivityDetailsParser parser = new HuamiActivityDetailsParser(summary); - parser.setSkipCounterByte(false); // is already stripped + if (success && buffer.size() > 0) { + if (detailsParser instanceof HuamiActivityDetailsParser) { + ((HuamiActivityDetailsParser) detailsParser).setSkipCounterByte(false); // is already stripped + } try { - ActivityTrack track = parser.parse(buffer.toByteArray()); + ActivityTrack track = detailsParser.parse(buffer.toByteArray()); ActivityTrackExporter exporter = createExporter(); String trackType = "track"; switch (summary.getActivityKind()) { @@ -112,6 +123,8 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation { trackType = getContext().getString(R.string.activity_type_swimming); break; } + final String rawBytesPath = saveRawBytes(); + String fileName = FileUtils.makeValidFileName("gadgetbridge-"+trackType.toLowerCase()+"-" + DateTimeUtils.formatIso8601(summary.getStartTime()) + ".gpx"); File targetFile = new File(FileUtils.getExternalFilesDir(), fileName); @@ -120,21 +133,35 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation { try (DBHandler dbHandler = GBApplication.acquireDB()) { summary.setGpxTrack(targetFile.getAbsolutePath()); + if (rawBytesPath != null) { + summary.setRawDetailsPath(rawBytesPath); + } dbHandler.getDaoSession().getBaseActivitySummaryDao().update(summary); } } catch (ActivityTrackExporter.GPXTrackEmptyException ex) { GB.toast(getContext(), "This activity does not contain GPX tracks.", Toast.LENGTH_LONG, GB.ERROR, ex); } - - GregorianCalendar endTime = BLETypeConversions.createCalendar(); - endTime.setTime(summary.getEndTime()); - saveLastSyncTimestamp(endTime); } catch (Exception ex) { GB.toast(getContext(), "Error getting activity details: " + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex); + parseSuccess = false; } } - super.handleActivityFetchFinish(success); + if (success && parseSuccess) { + // Always increment the sync timestamp on success, even if we did not get data + GregorianCalendar endTime = BLETypeConversions.createCalendar(); + endTime.setTime(summary.getEndTime()); + saveLastSyncTimestamp(endTime); + } + + final boolean superSuccess = super.handleActivityFetchFinish(success); + + return superSuccess && parseSuccess; + } + + @Override + protected boolean validChecksum(int crc32) { + return crc32 == CheckSums.getCRC32(buffer.toByteArray()); } private ActivityTrackExporter createExporter() { @@ -198,4 +225,23 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation { calendar.setTime(summary.getStartTime()); return calendar; } + + private String saveRawBytes() { + final String fileName = FileUtils.makeValidFileName(String.format("%s.bin", DateTimeUtils.formatIso8601(summary.getStartTime()))); + FileOutputStream outputStream = null; + + try { + final File targetFolder = new File(FileUtils.getExternalFilesDir(), "rawDetails"); + targetFolder.mkdirs(); + final File targetFile = new File(targetFolder, fileName); + outputStream = new FileOutputStream(targetFile); + outputStream.write(buffer.toByteArray()); + outputStream.close(); + return targetFile.getAbsolutePath(); + } catch (final IOException e) { + LOG.error("Failed to save raw bytes", e); + } + + return null; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsSummaryOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsSummaryOperation.java index 531312423..b22f01dce 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsSummaryOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsSummaryOperation.java @@ -32,14 +32,21 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.Logging; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021ActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipService; 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.ActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021ActivityDetailsParser; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.GB; /** @@ -63,7 +70,7 @@ public class FetchSportsSummaryOperation extends AbstractFetchOperation { } @Override - protected void handleActivityFetchFinish(boolean success) { + protected boolean handleActivityFetchFinish(boolean success) { LOG.info(getName() + " has finished round " + fetchCount); // GregorianCalendar lastSyncTimestamp = saveSamples(); @@ -77,12 +84,16 @@ public class FetchSportsSummaryOperation extends AbstractFetchOperation { // } BaseActivitySummary summary = null; - if (success) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(getDevice()); + final ActivitySummaryParser summaryParser = coordinator.getActivitySummaryParser(getDevice()); + + boolean parseSummarySuccess = true; + + if (success && buffer.size() > 0) { summary = new BaseActivitySummary(); summary.setStartTime(getLastStartTimestamp().getTime()); // due to a bug this has to be set summary.setRawSummaryData(buffer.toByteArray()); - HuamiActivitySummaryParser parser = new HuamiActivitySummaryParser(); - summary = parser.parseBinaryData(summary); + summary = summaryParser.parseBinaryData(summary); if (summary != null) { summary.setSummaryData(null); // remove json before saving to database, try (DBHandler dbHandler = GBApplication.acquireDB()) { @@ -95,21 +106,32 @@ public class FetchSportsSummaryOperation extends AbstractFetchOperation { session.getBaseActivitySummaryDao().insertOrReplace(summary); } catch (Exception ex) { GB.toast(getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, ex); + parseSummarySuccess = false; } } } - super.handleActivityFetchFinish(success); + final boolean superSuccess = super.handleActivityFetchFinish(success); + boolean getDetailsSuccess = true; if (summary != null) { - FetchSportsDetailsOperation nextOperation = new FetchSportsDetailsOperation(summary, getSupport(), getLastSyncTimeKey()); + final AbstractHuamiActivityDetailsParser detailsParser = ((HuamiActivitySummaryParser) summaryParser).getDetailsParser(summary); + + FetchSportsDetailsOperation nextOperation = new FetchSportsDetailsOperation(summary, detailsParser, getSupport(), getLastSyncTimeKey()); try { nextOperation.perform(); } catch (IOException ex) { GB.toast(getContext(), "Unable to fetch activity details: " + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex); + getDetailsSuccess = false; } } + return parseSummarySuccess && superSuccess && getDetailsSuccess; + } + + @Override + protected boolean validChecksum(int crc32) { + return crc32 == CheckSums.getCRC32(buffer.toByteArray()); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java index a17b3c840..edbf915c4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java @@ -36,6 +36,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipS import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -80,16 +81,24 @@ public class HuamiFetchDebugLogsOperation extends AbstractFetchOperation { } @Override - protected void handleActivityFetchFinish(boolean success) { - LOG.info(getName() +" data has finished"); + protected boolean handleActivityFetchFinish(boolean success) { + LOG.info("{} data has finished", getName()); try { logOutputStream.close(); logOutputStream = null; } catch (IOException e) { LOG.warn("could not close output stream", e); - return; + return false; } - super.handleActivityFetchFinish(success); + + return super.handleActivityFetchFinish(success); + } + + @Override + protected boolean validChecksum(int crc32) { + // TODO actually check it? + LOG.warn("Checksum not implemented for debug logs, assuming it's valid"); + return true; } @Override diff --git a/app/src/main/proto/huami.proto b/app/src/main/proto/huami.proto new file mode 100644 index 000000000..d70f9f764 --- /dev/null +++ b/app/src/main/proto/huami.proto @@ -0,0 +1,80 @@ +syntax = "proto3"; + +option java_package = "nodomain.freeyourgadget.gadgetbridge.proto"; +option java_outer_classname = "HuamiProtos"; + +message WorkoutSummary { + string version = 1; + Location location = 2; + Type type = 3; + Distance distance = 4; + Steps steps = 11; + Time time = 7; + Pace pace = 10; + HeartRate heartRate = 19; + Calories calories = 16; + TrainingEffect trainingEffect = 21; + HeartRateZones heartRateZones = 22; +} + +message Location { + // TODO 1, 2, 3 + int32 baseLatitude = 5; // /6000000 -> coords + int32 baseLongitude = 6; // /-6000000 -> coords + int32 baseAltitude = 7; // /2 -> meters + int32 maxLatitude = 8; // /3000000 -> coords + int32 minLatitude = 9; // /3000000 -> coords + int32 maxLongitude = 10; // /3000000 -> coords + int32 minLongitude = 11; // /3000000 -> coords +} + +message HeartRate { + int32 avg = 1; // bpm + int32 max = 2; // bpm + int32 min = 3; // bpm +} + +message Steps { + float avgCadence = 1; // steps/sec + float maxCadence = 2; // steps/sec + int32 avgStride = 3; // cm + int32 steps = 4; // count +} + +message Type { + int32 type = 1; // 1 = running, 4 = bike, 3 = walk + // TODO 2, always 0? +} + +message Distance { + float distance = 1; // meters +} + +message Time { + int32 totalDuration = 1; // seconds + int32 workoutDuration = 2; // seconds + int32 pauseDuration = 3; // seconds +} + +message Pace { + float avg = 1; // val * 1000 / 60 -> min/km + float best = 2; // val * 1000 / 60 -> min/km +} + +message Calories { + int32 calories = 1; // kcal +} + +message HeartRateZones { + // TODO 1, is always = 1? + // Zones: N/A, Warm-up, Fat-burn time, Aerobic, Anaerobic, Extreme + repeated int32 zoneMax = 2; // bpm + repeated int32 zoneTime = 3; // seconds +} + +message TrainingEffect { + float aerobicTrainingEffect = 4; + float anaerobicTrainingEffect = 5; + int32 currentWorkoutLoad = 6; + int32 maximumOxygenUptake = 7; // ml/kg/min +} diff --git a/app/src/main/proto/smaq2oss.proto b/app/src/main/proto/smaq2oss.proto index 2e0de5205..aca887950 100644 --- a/app/src/main/proto/smaq2oss.proto +++ b/app/src/main/proto/smaq2oss.proto @@ -1,6 +1,6 @@ syntax = "proto3"; -option java_package = "nodomain.freeyourgadget.gadgetbridge"; +option java_package = "nodomain.freeyourgadget.gadgetbridge.proto"; option java_outer_classname = "SMAQ2OSSProtos"; message SetTime diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 651612f64..5134e5c76 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,7 @@ Call Dismissal Update on device Developer options + Authentication Mi Band address Pebble settings Activity trackers @@ -723,7 +724,7 @@ Average: %1$s Do not ACK activity data transfer If not ACKed to the band, activity data is not cleared. Useful if GB is used together with other apps. - Will keep activity data on the Mi Band even after synchronization. Useful if GB is used together with other apps. + Will keep activity data on the device even after synchronization. Useful if GB is used together with other apps. Use low-latency mode for firmware flashing This might help on devices where firmware flashing fails. Allow 3rd party apps to change settings @@ -1459,6 +1460,8 @@ 7 days 30 days Time period + Delete %d activities + Are you sure you want to delete %d activities? All devices distant past today diff --git a/app/src/main/res/xml/devicesettings_header_authentication.xml b/app/src/main/res/xml/devicesettings_header_authentication.xml new file mode 100644 index 000000000..9050be3e9 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_header_authentication.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/xml/devicesettings_header_developer.xml b/app/src/main/res/xml/devicesettings_header_developer.xml new file mode 100644 index 000000000..961e98776 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_header_developer.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/xml/devicesettings_keep_activity_data_on_device.xml b/app/src/main/res/xml/devicesettings_keep_activity_data_on_device.xml new file mode 100644 index 000000000..c2809866e --- /dev/null +++ b/app/src/main/res/xml/devicesettings_keep_activity_data_on_device.xml @@ -0,0 +1,9 @@ + + + +