mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2025-01-10 17:11:56 +01:00
Zepp OS: Implement workout fetching
This commit is contained in:
parent
7f4bd16914
commit
d1ae6cf225
@ -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);
|
||||
|
@ -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/'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,9 +17,11 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
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<BaseActivity
|
||||
SparseBooleanArray checked = getItemListView().getCheckedItemPositions();
|
||||
switch (menuItem.getItemId()) {
|
||||
case R.id.activity_action_delete:
|
||||
List<BaseActivitySummary> toDelete = new ArrayList<>();
|
||||
final List<BaseActivitySummary> 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:
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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<Bas
|
||||
ImageView activityIconView = view.findViewById(R.id.summary_dashboard_layout_activity_icon);
|
||||
ImageView activityIconBigView = view.findViewById(R.id.summary_dashboard_layout_big_activity_icon);
|
||||
|
||||
final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(device);
|
||||
|
||||
for (BaseActivitySummary sportitem : getItems()) {
|
||||
if (sportitem.getStartTime() == null) continue; //first item is empty, for dashboard
|
||||
|
||||
@ -199,8 +204,8 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<Bas
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(sportitem);
|
||||
final ActivitySummaryParser summaryParser = coordinator.getActivitySummaryParser(device);
|
||||
final ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(summaryParser, sportitem);
|
||||
JSONObject summarySubdata = activitySummaryJsonSummary.getSummaryData();
|
||||
|
||||
if (summarySubdata != null) {
|
||||
|
@ -0,0 +1,38 @@
|
||||
/* 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 <http://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) {
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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<? extends ActivitySample> 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...
|
||||
|
@ -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 <http://www.gnu.org/licenses/>. */
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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 };
|
||||
|
@ -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;
|
||||
|
||||
|
@ -16,14 +16,9 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
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.
|
||||
*
|
||||
|
@ -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();
|
||||
}
|
||||
|
2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/proto/.gitignore
vendored
Normal file
2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/proto/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# This folder will contain auto-generated protobuf classes
|
||||
*.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));
|
||||
}
|
||||
|
@ -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 <http://www.gnu.org/licenses/>. */
|
||||
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;
|
||||
}
|
||||
}
|
@ -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 <http://www.gnu.org/licenses/>. */
|
||||
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<Byte, Integer> 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<Byte, Integer> 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
80
app/src/main/proto/huami.proto
Normal file
80
app/src/main/proto/huami.proto
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
|
@ -246,6 +246,7 @@
|
||||
<string name="pref_title_canned_messages_dismisscall">Call Dismissal</string>
|
||||
<string name="pref_title_canned_messages_set">Update on device</string>
|
||||
<string name="pref_header_development">Developer options</string>
|
||||
<string name="pref_header_authentication">Authentication</string>
|
||||
<string name="pref_title_development_miaddr">Mi Band address</string>
|
||||
<string name="pref_title_pebble_settings">Pebble settings</string>
|
||||
<string name="pref_header_activitytrackers">Activity trackers</string>
|
||||
@ -723,7 +724,7 @@
|
||||
<string name="average">Average: %1$s</string>
|
||||
<string name="pref_title_dont_ack_transfer">Do not ACK activity data transfer</string>
|
||||
<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>
|
||||
<string name="pref_summary_keep_data_on_device">Will keep activity data on the Mi Band even after synchronization. Useful if GB is used together with other apps.</string>
|
||||
<string name="pref_summary_keep_data_on_device">Will keep activity data on the device even after synchronization. Useful if GB is used together with other apps.</string>
|
||||
<string name="pref_title_low_latency_fw_update">Use low-latency mode for firmware flashing</string>
|
||||
<string name="pref_summary_low_latency_fw_update">This might help on devices where firmware flashing fails.</string>
|
||||
<string name="pref_title_third_party_app_device_settings">Allow 3rd party apps to change settings</string>
|
||||
@ -1459,6 +1460,8 @@
|
||||
<string name="sports_activity_quick_filter_7days">7 days</string>
|
||||
<string name="sports_activity_quick_filter_30days">30 days</string>
|
||||
<string name="sports_activity_quick_filter_select">Time period</string>
|
||||
<string name="sports_activity_confirm_delete_title">Delete %d activities</string>
|
||||
<string name="sports_activity_confirm_delete_description">Are you sure you want to delete %d activities?</string>
|
||||
<string name="activity_summaries_all_devices">All devices</string>
|
||||
<string name="activity_filter_from_placeholder">distant past</string>
|
||||
<string name="activity_filter_to_placeholder">today</string>
|
||||
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<PreferenceCategory
|
||||
android:key="pref_header_authentication"
|
||||
android:title="@string/pref_header_authentication" />
|
||||
</androidx.preference.PreferenceScreen>
|
6
app/src/main/res/xml/devicesettings_header_developer.xml
Normal file
6
app/src/main/res/xml/devicesettings_header_developer.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<PreferenceCategory
|
||||
android:key="pref_header_development"
|
||||
android:title="@string/pref_header_development" />
|
||||
</androidx.preference.PreferenceScreen>
|
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<SwitchPreference
|
||||
android:icon="@drawable/ic_activity_unknown_small"
|
||||
android:defaultValue="false"
|
||||
android:key="keep_activity_data_on_device"
|
||||
android:summary="@string/pref_summary_keep_data_on_device"
|
||||
android:title="@string/pref_title_keep_data_on_device" />
|
||||
</androidx.preference.PreferenceScreen>
|
Loading…
Reference in New Issue
Block a user