From 11d1fd08bdc2fee5945288324baa32fef6e213f4 Mon Sep 17 00:00:00 2001 From: dakhnod Date: Mon, 20 Apr 2020 00:11:45 +0200 Subject: [PATCH] Add Fossil HR Activity Tracking (#1846) --- .../gadgetbridge/daogen/GBDaoGenerator.java | 20 ++- .../HybridHRActivitySampleProvider.java | 75 ++++++++++ .../devices/qhybrid/QHybridCoordinator.java | 6 +- .../AbstractHybridHRActivitySample.java | 24 ++++ .../fossil_hr/FossilHRWatchAdapter.java | 117 +++++++++++---- .../devices/qhybrid/parser/ActivityEntry.java | 65 +++++++++ .../qhybrid/parser/ActivityFileParser.java | 136 ++++++++++++++++++ .../fossil/alarm/AlarmsGetRequest.java | 12 ++ .../fossil/file/FileLookupRequest.java | 18 ++- .../activity/ActivityFilesGetRequest.java | 15 ++ .../ConfigurationGetRequest.java | 11 ++ .../file/FileEncryptedGetRequest.java | 85 +++++++---- .../freeyourgadget/gadgetbridge/util/GB.java | 3 + 13 files changed, 522 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRActivitySampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHybridHRActivitySample.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityEntry.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/activity/ActivityFilesGetRequest.java diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index d7256f6ff..384fad9ac 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 { - Schema schema = new Schema(24, MAIN_PACKAGE + ".entities"); + Schema schema = new Schema(25, MAIN_PACKAGE + ".entities"); Entity userAttributes = addUserAttributes(schema); Entity user = addUserInfo(schema, userAttributes); @@ -71,6 +71,7 @@ public class GBDaoGenerator { addZeTimeActivitySample(schema, user, device); addID115ActivitySample(schema, user, device); addJYouActivitySample(schema, user, device); + addHybridHRActivitySample(schema, user, device); addCalendarSyncState(schema, device); addAlarms(schema, user, device); @@ -341,6 +342,23 @@ public class GBDaoGenerator { return activitySample; } + private static Entity addHybridHRActivitySample(Schema schema, Entity user, Entity device) { + Entity activitySample = addEntity(schema, "HybridHRActivitySample"); + activitySample.implementsSerializable(); + + addCommonActivitySampleProperties("AbstractHybridHRActivitySample", activitySample, user, device); + + activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty("calories").notNull(); + activitySample.addIntProperty("variability").notNull(); + activitySample.addIntProperty("max_variability").notNull(); + activitySample.addIntProperty("heartrate_quality").notNull(); + activitySample.addBooleanProperty("active").notNull(); + activitySample.addByteProperty("wear_type").notNull(); + addHeartRateProperties(activitySample); + return activitySample; + } + private static void addCommonActivitySampleProperties(String superClass, Entity activitySample, Entity user, Entity device) { activitySample.setSuperclass(superClass); activitySample.addImport(MAIN_PACKAGE + ".devices.SampleProvider"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRActivitySampleProvider.java new file mode 100644 index 000000000..60af510ad --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRActivitySampleProvider.java @@ -0,0 +1,75 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.List; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser.ActivityEntry; + +public class HybridHRActivitySampleProvider extends AbstractSampleProvider { + public HybridHRActivitySampleProvider(GBDevice device, DaoSession session) { + super(device, session); + } + + @Override + public AbstractDao getSampleDao() { + return getSession().getHybridHRActivitySampleDao(); + } + + @Nullable + @Override + protected Property getRawKindSampleProperty() { + return null; + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return HybridHRActivitySampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return HybridHRActivitySampleDao.Properties.DeviceId; + } + + @Override + public int normalizeType(int rawType) { + if(rawType == -1) return 0; + return ActivityEntry.WEARING_STATE.fromValue((byte) rawType).getActivityKind(); + } + + @Override + public int toRawActivityKind(int activityKind) { + return 0; + } + + @Override + public float normalizeIntensity(int rawIntensity) { + return rawIntensity / 63f; + } + + @Override + public HybridHRActivitySample createActivitySample() { + return new HybridHRActivitySample(); + } + + @Override + public List getActivitySamples(int timestamp_from, int timestamp_to) { + return super.getActivitySamples(timestamp_from, timestamp_to); + } + + @Override + public List getAllActivitySamples(int timestamp_from, int timestamp_to) { + return super.getAllActivitySamples(timestamp_from, timestamp_to); + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java index 6cfd1f57c..28d5e3d08 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java @@ -85,7 +85,7 @@ public class QHybridCoordinator extends AbstractDeviceCoordinator { @Override public boolean supportsActivityTracking() { - return false; + return true; } @Override @@ -95,7 +95,7 @@ public class QHybridCoordinator extends AbstractDeviceCoordinator { @Override public SampleProvider getSampleProvider(GBDevice device, DaoSession session) { - return null; + return new HybridHRActivitySampleProvider(device, session); } @Override @@ -132,7 +132,7 @@ public class QHybridCoordinator extends AbstractDeviceCoordinator { @Override public boolean supportsHeartRateMeasurement(GBDevice device) { - return false; + return this.isHybridHR(); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHybridHRActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHybridHRActivitySample.java new file mode 100644 index 000000000..6c9215d65 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHybridHRActivitySample.java @@ -0,0 +1,24 @@ +package nodomain.freeyourgadget.gadgetbridge.entities; + +public abstract class AbstractHybridHRActivitySample extends AbstractActivitySample { + abstract public int getCalories(); + abstract public byte getWear_type(); + + @Override + public int getRawKind() { + return getWear_type(); + } + + @Override + public int getRawIntensity() { + return getCalories(); + } + + @Override + public void setUserId(long userId) {} + + @Override + public long getUserId() { + return 0; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java index a89cbf17e..1b760b0e9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java @@ -34,11 +34,16 @@ import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.HRConfigActivity; +import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.HybridHRActivitySampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.NotificationHRConfiguration; +import nodomain.freeyourgadget.gadgetbridge.devices.zetime.ZeTimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; @@ -49,16 +54,23 @@ import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.fossil.FossilWatchAdapter; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser.ActivityEntry; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser.ActivityFileParser; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.RequestMtuRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.SetDeviceStateRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.configuration.ConfigurationPutRequest.TimeConfigItem; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.file.FileDeleteRequest; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.file.FileGetRequest; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.file.FileLookupRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.notification.PlayCallNotificationRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.notification.PlayTextNotificationRequest; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.activity.ActivityFilesGetRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.authentication.VerifyPrivateKeyRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.buttons.ButtonConfigurationPutRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.configuration.ConfigurationGetRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.configuration.ConfigurationPutRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.file.AssetFilePutRequest; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.file.FileEncryptedGetRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.file.FirmwareFilePutRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.AssetImage; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.AssetImageFactory; @@ -74,6 +86,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fos import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomWidgetElement; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.Widget; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.WidgetsPutRequest; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.ListFilesRequest; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; @@ -488,6 +501,64 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { @Override public void onFetchActivityData() { syncSettings(); + + queueWrite(new VerifyPrivateKeyRequest(this.getSecretKey(), this)); + queueWrite(new FileLookupRequest((byte) 0x01, this){ + @Override + public void handleFileLookup(final short fileHandle) { + queueWrite(new FileEncryptedGetRequest(fileHandle, FossilHRWatchAdapter.this) { + @Override + public void handleFileData(byte[] fileData) { + try (DBHandler dbHandler = GBApplication.acquireDB()) { + ActivityFileParser parser = new ActivityFileParser(); + ArrayList entries = parser.parseFile(fileData); + HybridHRActivitySampleProvider provider = new HybridHRActivitySampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession()); + + HybridHRActivitySample[] samples = new HybridHRActivitySample[entries.size()]; + + for(int i = 0; i < entries.size(); i++){ + samples[i] = entries.get(i).toDAOActivitySample(DBHelper.getDevice(getDeviceSupport().getDevice(), dbHandler.getDaoSession()).getId()); + } + + provider.addGBActivitySamples(samples); + + writeFile(String.valueOf(System.currentTimeMillis()), fileData); + queueWrite(new FileDeleteRequest(fileHandle)); + GB.toast("synced activity data", Toast.LENGTH_SHORT, GB.INFO); + } catch (Exception ex) { + GB.toast(getContext(), "Error saving steps data: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); + GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext()); + } + getDeviceSupport().getDevice().sendDeviceUpdateIntent(getContext()); + } + }); + } + + @Override + public void handleFileLookupError(FILE_LOOKUP_ERROR error) { + if(error == FILE_LOOKUP_ERROR.FILE_EMPTY){ + GB.toast("activity file empty yet", Toast.LENGTH_LONG, GB.ERROR); + }else{ + throw new RuntimeException("strange lookup stuff"); + } + getDeviceSupport().getDevice().sendDeviceUpdateIntent(getContext()); + } + }); + } + + private void writeFile(String fileName, byte[] value){ + File activityDir = new File(getContext().getExternalFilesDir(null), "activity_hr"); + activityDir.mkdir(); + File f = new File(activityDir, fileName); + try { + f.createNewFile(); + FileOutputStream fos = new FileOutputStream(f); + fos.write(value); + fos.close(); + GB.toast("saved file data", Toast.LENGTH_SHORT, GB.INFO); + } catch (IOException e) { + GB.log("file error", GB.ERROR, e); + } } private void syncSettings() { @@ -670,35 +741,28 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { } } - - // this was used to enumerate the weather icons :) - /* - static int i = 0; - @Override public void onTestNewFunction() { - long ts = System.currentTimeMillis(); - ts /= 1000; - try { - JSONObject responseObject = new JSONObject() - .put("res", new JSONObject() - .put("id", 0) // seems the id does not matter? - .put("set", new JSONObject() - .put("weatherInfo", new JSONObject() - .put("alive", ts + 60 * 60) - .put("unit", "c") - .put("temp", i) - .put("cond_id", i++) - ) - )); - - queueWrite(new JsonPutRequest(responseObject, this)); - - } catch (JSONException e) { - logger.error(" JSON exception: ", e); - } + /*queueWrite(new ActivityFilesGetRequest(this){ + @Override + public void handleFileData(byte[] fileData) { + super.handleFileData(fileData); + File activityDir = new File(getContext().getExternalFilesDir(null), "activity_hr"); + activityDir.mkdir(); + File f = new File(activityDir, String.valueOf(System.currentTimeMillis())); + try { + f.createNewFile(); + FileOutputStream fos = new FileOutputStream(f); + fos.write(fileData); + fos.close(); + GB.toast("saved file data", Toast.LENGTH_SHORT, GB.INFO); + } catch (IOException e) { + GB.log("activity file error", GB.ERROR, e); + } + queueWrite(new FileDeleteRequest((short) 0x0101)); + } + });*/ } -*/ @Override public void onInstallApp(Uri uri) { @@ -718,6 +782,7 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { } } + public byte[] getSecretKey() { byte[] authKeyBytes = new byte[16]; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityEntry.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityEntry.java new file mode 100644 index 000000000..9c7ba078f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityEntry.java @@ -0,0 +1,65 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser; + +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; + +public class ActivityEntry { + public int id; + public int heartRate; + public int variability, maxVariability; + public int calories; + public int stepCount; + public boolean isActive; + + public int timestamp; + + public int heartRateQuality; + + public WEARING_STATE wearingState; + + public HybridHRActivitySample toDAOActivitySample(long deviceId){ + HybridHRActivitySample sample = new HybridHRActivitySample( + timestamp, + deviceId, + -1, + stepCount, + calories, + variability, + maxVariability, + heartRateQuality, + isActive, + wearingState.value, + heartRate + ); + + return sample; + } + + public enum WEARING_STATE{ + WEARING((byte) 0, ActivityKind.TYPE_NOT_MEASURED), + NOT_WEARING((byte) 1, ActivityKind.TYPE_NOT_WORN), + UNKNOWN((byte) 2, ActivityKind.TYPE_UNKNOWN); + + byte value; + int activityKind; + + WEARING_STATE(byte value, int activityKind){ + this.value = value; + this.activityKind = activityKind; + } + + public int getActivityKind() { + return activityKind; + } + + static public WEARING_STATE fromValue(byte value){ + switch (value){ + case 0: return WEARING_STATE.WEARING; + case 1: return WEARING_STATE.NOT_WEARING; + case 2: return WEARING_STATE.UNKNOWN; + default: throw new RuntimeException("value " + value + " not valid state value"); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java new file mode 100644 index 000000000..f9b52fa54 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java @@ -0,0 +1,136 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; + +public class ActivityFileParser { + // state flags; + int heartRateQuality; + ActivityEntry.WEARING_STATE wearingState = ActivityEntry.WEARING_STATE.UNKNOWN; + int currentTimestamp = -1; + ActivityEntry currentSample = null; + int currentId = 1; + + public ArrayList parseFile(byte[] file) { + ByteBuffer buffer = ByteBuffer.wrap(file); + buffer.order(ByteOrder.LITTLE_ENDIAN); + + // read file version + short version = buffer.getShort(2); + if (version != 22) throw new RuntimeException("File version " + version + ", 16 required"); + + int startTime = buffer.getInt(8); + short timeOffsetMinutes = buffer.getShort(12); + + short fileId = buffer.getShort(16); + + buffer.position(20); + + ArrayList samples = new ArrayList<>(); + finishCurrentPacket(samples); + + while (buffer.position() < buffer.capacity() - 4) { + byte next = buffer.get(); + + if (paraseFlag(next, buffer, samples)) continue; + + if(currentSample != null) { + parseVariabilityBytes(next, buffer.get()); + + int heartRate = buffer.get() & 0xFF; + int calories = buffer.get() & 0xFF; + boolean isActive = (calories & 0x40) == 0x40; // upper two bits + calories &= 0x3F; // delete upper two bits + + currentSample.heartRate = heartRate; + currentSample.calories = calories; + currentSample.isActive = isActive; + finishCurrentPacket(samples); + } + } + return samples; + } + + private boolean paraseFlag(byte flag, ByteBuffer buffer, ArrayList samples) { + switch (flag) { + case (byte) 0xCA: + case (byte) 0xCB: + case (byte) 0xCC: + case (byte) 0xCD: + buffer.get(); + break; + case (byte) 0xCE: + byte arg = buffer.get(); + byte wearBits = (byte)((arg & 0b00011000) >> 3); + if(wearBits == 0) this.wearingState = ActivityEntry.WEARING_STATE.NOT_WEARING; + else if(wearBits == 1) this.wearingState = ActivityEntry.WEARING_STATE.WEARING; + else this.wearingState = ActivityEntry.WEARING_STATE.UNKNOWN; + + byte heartRateQualityBits = (byte)((arg & 0b11100000) >> 5); + this.heartRateQuality = heartRateQualityBits; + break; + case (byte) 0xCF: + case (byte) 0xDE: + case (byte) 0xDF: + case (byte) 0xE1: + buffer.get(); + break; + case (byte) 0xE2: + byte type = buffer.get(); + int timestamp = buffer.getInt(); + short duration = buffer.getShort(); + short minutesOffset = buffer.getShort(); + if (type == 0x04) { + this.currentTimestamp = timestamp; + } + break; + case (byte) 0xDD: + case (byte) 0xFD: + buffer.get(); + break; + case (byte) 0xFE: + byte arg2 = buffer.get(); + if(arg2 == (byte) 0xFE) { + // this.currentSample = new ActivitySample(); + // this.currentSample.id = currentId++; + } + break; + default: + return false; + } + return true; + } + + private void parseVariabilityBytes(byte lower, byte higher){ + if((lower & 0b0000001) == 0b0000001){ + currentSample.maxVariability = (higher & 0b00000011) * 25 + 1; + currentSample.stepCount = lower & 0b1110; + if((lower & 0b10000000) == 0b10000000){ + int factor = (lower >> 4) & 0b111; + currentSample.variability = 512 + factor * 64 + (higher >> 2 & 0b111111); + currentSample.stepCount = lower & 0b1110; + }else { + currentSample.variability = lower & 0b01110000; + currentSample.variability <<= 2; + currentSample.variability |= (higher >> 2) & 0b111111; + } + }else{ + currentSample.variability = (int) higher * (int) higher * 64; + currentSample.maxVariability = 10000; + } + } + + private void finishCurrentPacket(ArrayList samples) { + if (currentSample != null) { + currentSample.timestamp = currentTimestamp; + currentSample.heartRateQuality = this.heartRateQuality; + currentSample.wearingState = wearingState; + currentTimestamp += 60; + samples.add(currentSample); + currentSample = null; + } + this.currentSample = new ActivityEntry(); + this.currentSample.id = currentId++; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/alarm/AlarmsGetRequest.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/alarm/AlarmsGetRequest.java index 49600773c..3cd09d3a7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/alarm/AlarmsGetRequest.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/alarm/AlarmsGetRequest.java @@ -16,12 +16,15 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.alarm; +import android.widget.Toast; + import java.nio.ByteBuffer; import java.nio.ByteOrder; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.fossil.FossilWatchAdapter; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.file.FileGetRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.file.FileLookupAndGetRequest; +import nodomain.freeyourgadget.gadgetbridge.util.GB; public class AlarmsGetRequest extends FileLookupAndGetRequest { public AlarmsGetRequest(FossilWatchAdapter adapter) { @@ -59,4 +62,13 @@ public class AlarmsGetRequest extends FileLookupAndGetRequest { alarms2[i] = Alarm.fromBytes(alarms[i].getData()); } } + + @Override + public void handleFileLookupError(FILE_LOOKUP_ERROR error) { + if(error == FILE_LOOKUP_ERROR.FILE_EMPTY){ + GB.toast("alarm file empty yet", Toast.LENGTH_LONG, GB.ERROR); + }else{ + throw new RuntimeException("strange lookup stuff"); + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileLookupRequest.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileLookupRequest.java index 02facf09c..1a19ef812 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileLookupRequest.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileLookupRequest.java @@ -20,17 +20,14 @@ import android.bluetooth.BluetoothGattCharacteristic; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.util.ArrayList; import java.util.UUID; import java.util.zip.CRC32; -import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.fossil.FossilWatchAdapter; -import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.Request; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.FossilRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.file.ResultCode; -public class FileLookupRequest extends FossilRequest { +public abstract class FileLookupRequest extends FossilRequest { private short handle = -1; private byte fileType; @@ -91,6 +88,11 @@ public class FileLookupRequest extends FossilRequest { // throw new RuntimeException("handle: " + handle + " expected: " + this.handle); } log("file size: " + size); + if(size == 0){ + this.handleFileLookupError(FILE_LOOKUP_ERROR.FILE_EMPTY); + finished = true; + return; + } fileBuffer = ByteBuffer.allocate(size); }else if((first & 0x0F) == 8){ this.finished = true; @@ -122,7 +124,9 @@ public class FileLookupRequest extends FossilRequest { } } - public void handleFileLookup(short fileHandle){} + public abstract void handleFileLookup(short fileHandle); + + public abstract void handleFileLookupError(FILE_LOOKUP_ERROR error); @Override public UUID getRequestUUID() { @@ -138,4 +142,8 @@ public class FileLookupRequest extends FossilRequest { public int getPayloadLength() { return 3; } + + public enum FILE_LOOKUP_ERROR{ + FILE_EMPTY; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/activity/ActivityFilesGetRequest.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/activity/ActivityFilesGetRequest.java new file mode 100644 index 000000000..5f1f9b0fe --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/activity/ActivityFilesGetRequest.java @@ -0,0 +1,15 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.activity; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.fossil_hr.FossilHRWatchAdapter; +import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.file.FileEncryptedGetRequest; + +public class ActivityFilesGetRequest extends FileEncryptedGetRequest { + public ActivityFilesGetRequest(FossilHRWatchAdapter adapter) { + super((short) 0x0101, adapter); + } + + @Override + public void handleFileData(byte[] fileData) { + assert Boolean.TRUE; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/configuration/ConfigurationGetRequest.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/configuration/ConfigurationGetRequest.java index d5015f064..27153a18a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/configuration/ConfigurationGetRequest.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/configuration/ConfigurationGetRequest.java @@ -1,5 +1,7 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.configuration; +import android.widget.Toast; + import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; @@ -54,4 +56,13 @@ public class ConfigurationGetRequest extends FileEncryptedLookupAndGetRequest { device.sendDeviceUpdateIntent(getAdapter().getContext()); } + + @Override + public void handleFileLookupError(FILE_LOOKUP_ERROR error) { + if(error == FILE_LOOKUP_ERROR.FILE_EMPTY){ + GB.toast("config file empty", Toast.LENGTH_LONG, GB.ERROR); + }else{ + throw new RuntimeException("strange lookup stuff"); + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/FileEncryptedGetRequest.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/FileEncryptedGetRequest.java index 867bf49e3..2b3379f8b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/FileEncryptedGetRequest.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/FileEncryptedGetRequest.java @@ -23,7 +23,6 @@ import java.nio.ByteOrder; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; import java.util.UUID; import java.util.zip.CRC32; @@ -34,11 +33,10 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; -import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.fossil.FossilWatchAdapter; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.fossil_hr.FossilHRWatchAdapter; -import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.Request; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.FossilRequest; +import nodomain.freeyourgadget.gadgetbridge.util.CRC32C; public abstract class FileEncryptedGetRequest extends FossilRequest { private short handle; @@ -50,6 +48,10 @@ public abstract class FileEncryptedGetRequest extends FossilRequest { private boolean finished = false; + private Cipher cipher; + private SecretKeySpec keySpec; + private byte[] iv; + public FileEncryptedGetRequest(short handle, FossilHRWatchAdapter adapter) { this.handle = handle; this.adapter = adapter; @@ -62,12 +64,42 @@ public abstract class FileEncryptedGetRequest extends FossilRequest { .array(); } + private void initDecryption() { + try { + cipher = Cipher.getInstance("AES/CTR/NoPadding"); + keySpec = new SecretKeySpec(this.adapter.getSecretKey(), "AES"); + + + iv = new byte[16]; + + + byte[] phoneRandomNumber = adapter.getPhoneRandomNumber(); + byte[] watchRandomNumber = adapter.getWatchRandomNumber(); + + System.arraycopy(phoneRandomNumber, 0, iv, 2, 6); + System.arraycopy(watchRandomNumber, 0, iv, 9, 7); + + iv[7]++; + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + e.printStackTrace(); + } + } + public FossilWatchAdapter getAdapter() { return adapter; } + private void incrementIV(){ + ByteBuffer buffer = ByteBuffer.wrap(this.iv); + int number = buffer.getInt(12); + number += 0x1F; + buffer.position(12); + buffer.putInt(number); + this.iv = buffer.array(); + } + @Override - public boolean isFinished(){ + public boolean isFinished() { return finished; } @@ -75,73 +107,66 @@ public abstract class FileEncryptedGetRequest extends FossilRequest { public void handleResponse(BluetoothGattCharacteristic characteristic) { byte[] value = characteristic.getValue(); byte first = value[0]; - if(characteristic.getUuid().toString().equals("3dda0003-957f-7d4a-34a6-74696673696d")){ - if((first & 0x0F) == 1){ + if (characteristic.getUuid().toString().equals("3dda0003-957f-7d4a-34a6-74696673696d")) { + if ((first & 0x0F) == 1) { ByteBuffer buffer = ByteBuffer.wrap(value); buffer.order(ByteOrder.LITTLE_ENDIAN); + this.initDecryption(); + short handle = buffer.getShort(1); int size = buffer.getInt(4); byte status = buffer.get(3); ResultCode code = ResultCode.fromCode(status); - if(!code.inidicatesSuccess()){ + if (!code.inidicatesSuccess()) { throw new RuntimeException("FileGet error: " + code + " (" + status + ")"); } - if(this.handle != handle){ + if (this.handle != handle) { throw new RuntimeException("handle: " + handle + " expected: " + this.handle); } log("file size: " + size); fileBuffer = ByteBuffer.allocate(size); - }else if((first & 0x0F) == 8){ + } else if ((first & 0x0F) == 8) { this.finished = true; ByteBuffer buffer = ByteBuffer.wrap(value); buffer.order(ByteOrder.LITTLE_ENDIAN); short handle = buffer.getShort(1); - if(this.handle != handle){ + if (this.handle != handle) { throw new RuntimeException("handle: " + handle + " expected: " + this.handle); } CRC32 crc = new CRC32(); crc.update(this.fileData); + CRC32C c = new CRC32C(); + c.update(this.fileData, 0, fileData.length); + int crcExpected = buffer.getInt(8); - if((int) crc.getValue() != crcExpected){ + if ((int) crc.getValue() != crcExpected) { throw new RuntimeException("crc: " + crc.getValue() + " expected: " + crcExpected); } this.handleFileData(this.fileData); } - }else if(characteristic.getUuid().toString().equals("3dda0004-957f-7d4a-34a6-74696673696d")){ - SecretKeySpec keySpec = new SecretKeySpec(this.adapter.getSecretKey(), "AES"); + } else if (characteristic.getUuid().toString().equals("3dda0004-957f-7d4a-34a6-74696673696d")) { try { - Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); - - byte[] fileIV = new byte[16]; - - - byte[] phoneRandomNumber = adapter.getPhoneRandomNumber(); - byte[] watchRandomNumber = adapter.getWatchRandomNumber(); - - System.arraycopy(phoneRandomNumber, 0, fileIV, 2, 6); - System.arraycopy(watchRandomNumber, 0, fileIV, 9, 7); - - fileIV[7]++; - - cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(fileIV)); - + cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv)); byte[] result = cipher.doFinal(value); + incrementIV(); + fileBuffer.put(result, 1, result.length - 1); - if((result[0] & 0x80) == 0x80){ + if ((result[0] & 0x80) == 0x80) { this.fileData = fileBuffer.array(); } - } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) { + } catch (BadPaddingException | IllegalBlockSizeException | InvalidAlgorithmParameterException | InvalidKeyException e) { + e.printStackTrace(); throw new RuntimeException(e); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java index f77f07964..628b2e0c6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java @@ -51,10 +51,12 @@ import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2; import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventScreenshot; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService; import static nodomain.freeyourgadget.gadgetbridge.GBApplication.isRunningOreoOrLater; +import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_RECORDED_DATA_TYPES; public class GB { @@ -112,6 +114,7 @@ public class GB { builder.addAction(R.drawable.ic_notification_disconnected, context.getString(R.string.controlcenter_disconnect), disconnectPendingIntent); if (GBApplication.isRunningLollipopOrLater() && DeviceHelper.getInstance().getCoordinator(device).supportsActivityDataFetching()) { //for some reason this fails on KK deviceCommunicationServiceIntent.setAction(DeviceService.ACTION_FETCH_RECORDED_DATA); + deviceCommunicationServiceIntent.putExtra(EXTRA_RECORDED_DATA_TYPES, ActivityKind.TYPE_ACTIVITY); PendingIntent fetchPendingIntent = PendingIntent.getService(context, 1, deviceCommunicationServiceIntent, PendingIntent.FLAG_ONE_SHOT); builder.addAction(R.drawable.ic_action_fetch_activity_data, context.getString(R.string.controlcenter_fetch_activity_data), fetchPendingIntent); }