weightList) {
+ WritableArray array = Arguments.createArray();
+ if (weightList == null || weightList.isEmpty()) return array;
+
+ for (Weight weight : weightList) {
+ array.pushMap(toWritableMap(weight));
+ }
+ return array;
+ }
+
+ private WritableMap toWritableMap(@Nullable Weight weight) {
+ WritableMap map = Arguments.createMap();
+ if (weight == null) return map;
+
+ map.putMap("time", toWritableMap(weight.getTimeRecorded()));
+ map.putDouble("weight", weight.getWeight());
+ map.putInt("bodyFat", weight.getBodyFat());
+ map.putString("comment", weight.getComment());
+ return map;
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/HealthProvider.java b/android/src/main/java/nl/sense/rninputkit/inputkit/HealthProvider.java
new file mode 100644
index 0000000..ae1aac4
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/HealthProvider.java
@@ -0,0 +1,342 @@
+package nl.sense.rninputkit.inputkit;
+
+import android.app.Activity;
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Pair;
+
+import java.lang.ref.WeakReference;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nl.sense.rninputkit.inputkit.InputKit.Callback;
+import nl.sense.rninputkit.inputkit.InputKit.Result;
+import nl.sense.rninputkit.inputkit.constant.IKStatus;
+import nl.sense.rninputkit.inputkit.constant.Interval;
+import nl.sense.rninputkit.inputkit.constant.SampleType.SampleName;
+import nl.sense.rninputkit.inputkit.entity.BloodPressure;
+import nl.sense.rninputkit.inputkit.entity.SensorDataPoint;
+import nl.sense.rninputkit.inputkit.entity.StepContent;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+import nl.sense.rninputkit.inputkit.entity.Weight;
+import nl.sense.rninputkit.inputkit.status.IKResultInfo;
+
+import static nl.sense.rninputkit.inputkit.constant.IKStatus.Code.IK_NOT_AVAILABLE;
+import static nl.sense.rninputkit.inputkit.constant.IKStatus.Code.IK_NOT_CONNECTED;
+
+/**
+ * This is a Health contract provider which should be implemented on each Health class variants.
+ * Eg : Google Fit, Samsung Health, etc.
+ *
+ * Make it as an abstract class in case needed to share variable between Health provider
+ *
+ * Created by panjiyudasetya on 10/13/17.
+ */
+
+public abstract class HealthProvider {
+ protected static final IKResultInfo UNREACHABLE_CONTEXT = new IKResultInfo(
+ IK_NOT_AVAILABLE,
+ IKStatus.INPUT_KIT_UNREACHABLE_CONTEXT);
+ protected static final IKResultInfo INPUT_KIT_NOT_CONNECTED = new IKResultInfo(
+ IK_NOT_CONNECTED,
+ String.format(
+ "%s! Make sure to ask request permission before using Input Kit API!",
+ IKStatus.INPUT_KIT_NOT_CONNECTED
+ ));
+ protected IReleasableHostProvider mReleasableHost;
+ private WeakReference mWeakContext;
+ private WeakReference mWeakHostActivity;
+
+ public HealthProvider(@NonNull Context context) {
+ this.mWeakContext = new WeakReference<>(context.getApplicationContext());
+ }
+
+ public HealthProvider(@NonNull Context context, @NonNull IReleasableHostProvider releasableHost) {
+ this(context);
+ mReleasableHost = releasableHost;
+ }
+
+ @SuppressWarnings("SpellCheckingInspection")
+ public enum ProviderType {
+ GOOGLE_FIT, SAMSUNG_HEALTH, GARMIN_SDK
+ }
+
+ /**
+ * Since our health provider bound to weak reference of current application context
+ * as well as the activity, then we might need to re-initiate instance class wrapper
+ */
+ protected interface IReleasableHostProvider {
+ /**
+ * Release wrapper health provider reference
+ */
+ void release();
+ }
+
+ /**
+ * Sensor tracking listener
+ *
+ * @param Expected data type result
+ */
+ @SuppressWarnings("SpellCheckingInspection")
+ public interface SensorListener {
+ void onSubscribe(@NonNull IKResultInfo info);
+
+ void onReceive(@NonNull T data);
+
+ void onUnsubscribe(@NonNull IKResultInfo info);
+ }
+
+ /**
+ * Get available context.
+ *
+ * @return {@link Context} current application context.
+ * Null will be returned whenever context has no longer available inside
+ * of {@link HealthProvider#mWeakContext}
+ */
+ @Nullable
+ public Context getContext() {
+ return mWeakContext.get();
+ }
+
+ /**
+ * Set current host activity.
+ * Typically it will be used to show an alert dialog since it bound to the Activity
+ *
+ * @param activity Current Host activity
+ */
+ public void setHostActivity(@Nullable Activity activity) {
+ mWeakHostActivity = new WeakReference<>(activity);
+ }
+
+ /**
+ * Get available host activity.
+ *
+ * @return {@link Activity} current host activity.
+ * Null will be returned whenever host activity has no longer available inside
+ * of {@link HealthProvider#mWeakHostActivity}
+ */
+ @Nullable
+ public Activity getHostActivity() {
+ return mWeakHostActivity == null ? null : mWeakHostActivity.get();
+ }
+
+ /**
+ * Handler function when application context no longer available
+ */
+ protected void onUnreachableContext() {
+ if (mReleasableHost != null) mReleasableHost.release();
+ }
+
+ /**
+ * Handler function when application context no longer available
+ *
+ * @param callback {@link Callback} listener
+ */
+ protected void onUnreachableContext(@NonNull Callback callback) {
+ callback.onNotAvailable(UNREACHABLE_CONTEXT);
+ if (mReleasableHost != null) mReleasableHost.release();
+ }
+
+ /**
+ * Handler function when application context no longer available
+ *
+ * @param callback {@link Result} listener
+ */
+ protected void onUnreachableContext(@NonNull Result callback) {
+ callback.onError(UNREACHABLE_CONTEXT);
+ if (mReleasableHost != null) mReleasableHost.release();
+ }
+
+ /**
+ * Call {@link Result#onError(IKResultInfo)} whenever Health provider is not connected.
+ *
+ * @param callback {@link Result} callback which to be handled
+ * @return True if available, False otherwise
+ */
+ protected boolean isAvailable(@NonNull Result callback) {
+ if (!isAvailable()) {
+ callback.onError(INPUT_KIT_NOT_CONNECTED);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Check Health provider availability.
+ *
+ * @return True if health provider is available, False otherwise.
+ */
+ public abstract boolean isAvailable();
+
+ /**
+ * Check permission status of specific sensor in Health provider.
+ * @param permissionTypes permission types of sensor that needs to be check
+ * @return True if health provider is available, False otherwise.
+ */
+ public abstract boolean isPermissionsAuthorised(String[] permissionTypes);
+
+ /**
+ * Authorize Health provider connection.
+ *
+ * @param callback {@link Callback} event listener
+ * @param permissionType permission type. in case specific handler required when asking input kit
+ * type.
+ */
+ public abstract void authorize(@NonNull Callback callback, String... permissionType);
+
+ /**
+ * Disconnect from Health provider
+ *
+ * @param callback {@link Result} event listener
+ */
+ public abstract void disconnect(@NonNull Result callback);
+
+ /**
+ * Get total distance of walk on specific time range.
+ *
+ * @param startTime epoch for the start date
+ * @param endTime epoch for the end date
+ * @param limit historical data limitation
+ * set to null if you want to calculate all available distance within specific range
+ * @param callback {@link Result} containing number of total distance
+ */
+ public abstract void getDistance(long startTime,
+ long endTime,
+ int limit,
+ @NonNull Result callback);
+
+ /**
+ * Get sample distance within specific time range.
+ *
+ * @param startTime epoch for the start date
+ * @param endTime epoch for the end date
+ * @param limit historical data limitation
+ * set to null if you want to calculate all available distance within specific range
+ * @param callback {@link Result} containing number of total distance
+ */
+ public abstract void getDistanceSamples(long startTime,
+ long endTime,
+ int limit,
+ @NonNull Result>> callback);
+
+ /**
+ * Get total Today steps count.
+ *
+ * @param callback {@link Result} containing number of total steps count
+ */
+ public abstract void getStepCount(@NonNull Result callback);
+
+ /**
+ * Get total steps count of specific range
+ *
+ * @param startTime epoch for the start date
+ * @param endTime epoch for the end date
+ * @param callback {@link Result} containing number of total steps count
+ */
+ public abstract void getStepCount(long startTime,
+ long endTime,
+ int limit,
+ @NonNull Result callback);
+
+ /**
+ * Return data distribution of step count value through out a specific range.
+ *
+ * @param startTime epoch for the start date of the range where the distribution should be calculated from.
+ * @param endTime epoch for the end date of the range where the distribution should be calculated from.
+ * @param interval Interval
+ * @param limit historical data limitation
+ * set to null if you want to calculate all available distance within specific range
+ * @param callback {@link Result} Steps content set if available.
+ **/
+ public abstract void getStepCountDistribution(long startTime,
+ long endTime,
+ @NonNull @Interval.IntervalName String interval,
+ int limit,
+ @NonNull Result callback);
+
+ /**
+ * Returns data contains sleep analysis data of a specific range. Sorted recent data first.
+ *
+ * @param startTime epoch for the start date of the range
+ * @param endTime epoch for the end date of the range
+ * @param callback {@link Result} containing a set of sleep analysis samples
+ */
+ // TODO: Define data type of sleep analysis samples Input Kit result
+ public abstract void getSleepAnalysisSamples(long startTime,
+ long endTime,
+ @NonNull InputKit.Result>> callback);
+
+ /**
+ * Get blood pressure history
+ *
+ * @param startTime epoch for the start date of the range
+ * @param endTime epoch for the end date of the range
+ * @param callback {@link Result} containing a set history of user blood pressure
+ */
+ public abstract void getBloodPressure(long startTime,
+ long endTime,
+ @NonNull Result> callback);
+
+ /**
+ * Get blood weight history
+ *
+ * @param startTime epoch for the start date of the range
+ * @param endTime epoch for the end date of the range
+ * @param callback {@link Result} containing a set history of user weight
+ */
+ public abstract void getWeight(long startTime,
+ long endTime,
+ @NonNull Result> callback);
+
+ /**
+ * Start monitoring health sensors.
+ *
+ * @param sensorType sensor type should be one of these {@link SampleName} sensor
+ * @param samplingRate sensor sampling rate.
+ * Sensor will be started every X-Time Unit, for instance : { 5, {@link TimeUnit#MINUTES} }.
+ * If sampling rate is unspecified it will be set to 10 minute interval.
+ * @param listener {@link SensorListener} sensor listener
+ */
+ public abstract void startMonitoring(@NonNull @SampleName String sensorType,
+ @NonNull Pair samplingRate,
+ @NonNull SensorListener listener);
+
+ /**
+ * Stop monitoring health sensors.
+ *
+ * @param sensorType Sensor type should be one of these {@link SampleName} sensor
+ * @param listener {@link SensorListener} sensor listener
+ */
+ public abstract void stopMonitoring(@NonNull @SampleName String sensorType,
+ @NonNull SensorListener listener);
+
+ /**
+ * Start tracking specific sensor.
+ *
+ * @param sensorType Sample type should be one of these {@link SampleName} sensor
+ * @param samplingRate sensor sampling rate.
+ * Sensor will be started every X-Time Unit, for instance : { 5, {@link TimeUnit#MINUTES} }.
+ * If sampling rate is unspecified it will be set to 10 minute interval.
+ * @param listener {@link SensorListener} sensor listener
+ */
+ public abstract void startTracking(@NonNull @SampleName String sensorType,
+ @NonNull Pair samplingRate,
+ @NonNull SensorListener listener);
+
+ /**
+ * Stop tracking specific sensor.
+ *
+ * @param sensorType Sample type should be one of these {@link SampleName} sensor
+ * @param listener {@link SensorListener} sensor listener
+ */
+ public abstract void stopTracking(@NonNull String sensorType,
+ @NonNull SensorListener listener);
+
+ /**
+ * Stop all tracking specific sensor.
+ *
+ * @param listener {@link SensorListener} sensor listener
+ */
+ public abstract void stopTrackingAll(@NonNull SensorListener listener);
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/HealthTrackerState.java b/android/src/main/java/nl/sense/rninputkit/inputkit/HealthTrackerState.java
new file mode 100644
index 0000000..01fa5d1
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/HealthTrackerState.java
@@ -0,0 +1,108 @@
+package nl.sense.rninputkit.inputkit;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import android.text.TextUtils;
+import android.util.Pair;
+
+import com.google.gson.JsonObject;
+
+import nl.sense.rninputkit.inputkit.constant.SampleType;
+import nl.sense.rninputkit.inputkit.helper.PreferenceHelper;
+
+import static nl.sense.rninputkit.inputkit.constant.SampleType.SampleName;
+import static nl.sense.rninputkit.inputkit.constant.SampleType.UNAVAILABLE;
+import static nl.sense.rninputkit.inputkit.constant.SampleType.checkFitSampleType;
+
+/**
+ * Created by panjiyudasetya on 7/26/17.
+ */
+
+public class HealthTrackerState {
+ private HealthTrackerState() { }
+
+ /**
+ * Stored sensor state in shared preference
+ *
+ * @param context Current application context
+ * @param stateKey Tracker state key
+ * @param newSensorState New sensor state.
+ * Sample name should be one of available {@link SampleName}
+ * If it's unavailable it will throw {@link IllegalStateException}
+ */
+ public static void save(@NonNull Context context,
+ @NonNull String stateKey,
+ @NonNull Pair newSensorState) {
+ validateState(newSensorState);
+
+ JsonObject sensorsState = PreferenceHelper.getAsJson(
+ context,
+ stateKey
+ );
+
+ sensorsState.addProperty(
+ newSensorState.first,
+ newSensorState.second
+ );
+
+ // update preference
+ PreferenceHelper.add(
+ context,
+ stateKey,
+ sensorsState.toString()
+ );
+ }
+
+ /**
+ * Stored sensor state in shared preference
+ *
+ * @param context Current application context
+ * @param stateKey Tracker state key
+ * @param enables New sensor state
+ */
+ public static void saveAll(@NonNull Context context,
+ @NonNull String stateKey,
+ @NonNull boolean enables) {
+
+ JsonObject sensorsState = PreferenceHelper.getAsJson(
+ context,
+ stateKey
+ );
+
+ sensorsState.addProperty(
+ SampleType.STEP_COUNT,
+ enables
+ );
+
+ sensorsState.addProperty(
+ SampleType.DISTANCE_WALKING_RUNNING,
+ enables
+ );
+
+ // update preference
+ PreferenceHelper.add(
+ context,
+ stateKey,
+ sensorsState.toString()
+ );
+ }
+
+ /**
+ * Helper function to validate incoming sensor state.
+ * @param state New sensor state.
+ * Sample name should be one of available {@link SampleName}
+ * If it's unavailable it will throw {@link IllegalStateException}
+ * @throws IllegalStateException
+ */
+ private static void validateState(@NonNull Pair state)
+ throws IllegalStateException {
+ @SampleName String sensorSample = state.first;
+ if (TextUtils.isEmpty(sensorSample) || checkFitSampleType(sensorSample).equals(UNAVAILABLE)) {
+ throw new IllegalStateException("INVALID_SENSOR_SAMPLE_TYPE!");
+ }
+
+ if (state.second == null) {
+ throw new IllegalStateException("UNSPECIFIED_SENSOR_STATE_VALUE!");
+ }
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/InputKit.java b/android/src/main/java/nl/sense/rninputkit/inputkit/InputKit.java
new file mode 100644
index 0000000..659812d
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/InputKit.java
@@ -0,0 +1,349 @@
+package nl.sense.rninputkit.inputkit;
+
+import android.app.Activity;
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Pair;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nl.sense.rninputkit.inputkit.HealthProvider.IReleasableHostProvider;
+import nl.sense.rninputkit.inputkit.HealthProvider.ProviderType;
+import nl.sense.rninputkit.inputkit.HealthProvider.SensorListener;
+import nl.sense.rninputkit.inputkit.constant.Interval;
+import nl.sense.rninputkit.inputkit.constant.SampleType;
+import nl.sense.rninputkit.inputkit.entity.BloodPressure;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+import nl.sense.rninputkit.inputkit.entity.SensorDataPoint;
+import nl.sense.rninputkit.inputkit.entity.StepContent;
+import nl.sense.rninputkit.inputkit.entity.Weight;
+import nl.sense.rninputkit.inputkit.shealth.SamsungHealthProvider;
+import nl.sense.rninputkit.inputkit.status.IKResultInfo;
+import nl.sense.rninputkit.inputkit.status.IKProviderInfo;
+import nl.sense.rninputkit.inputkit.googlefit.GoogleFitHealthProvider;
+
+/**
+ * Created by panjiyudasetya on 6/14/17.
+ */
+
+public class InputKit implements IReleasableHostProvider {
+ private static InputKit sInputKit;
+ private HealthProvider mCurrentHealthProvider;
+ private GoogleFitHealthProvider mGoogleFitHealthProvider;
+ private SamsungHealthProvider mSamsungHealthProvider;
+
+ /**
+ * A callback result for each Input Kit functionality.
+ *
+ * @param Expected result.
+ */
+ public interface Result {
+ /**
+ * Callback function to handle new available data
+ *
+ * @param data Expected data
+ */
+ void onNewData(T data);
+
+ /**
+ * Callback function to handle any exceptions if any
+ *
+ * @param error {@link IKResultInfo}
+ */
+ void onError(@NonNull IKResultInfo error);
+ }
+
+ public interface Callback {
+ /**
+ * This action will be triggered when successfully connected to Input Kit Service.
+ * @param addMessages additional message
+ */
+ void onAvailable(String... addMessages);
+
+ /**
+ * This event will be triggered when Input Kit is not available for some reason.
+ * @param reason Typically contains error code and error message.
+ */
+ void onNotAvailable(@NonNull IKResultInfo reason);
+
+ /**
+ * This event will be triggered whenever connection to Input Kit service has been rejected.
+ * In any case, the problem probably solved by call
+ * {@link com.google.android.gms.common.ConnectionResult#startResolutionForResult(Activity, int)}
+ * which int value should be referred to
+ * {@link com.google.android.gms.common.ConnectionResult#getErrorCode()}.
+ * But this action required UI interaction, so be careful with it.
+ * @param connectionError {@link IKProviderInfo}
+ */
+ void onConnectionRefused(@NonNull IKProviderInfo connectionError);
+ }
+
+ private InputKit(@NonNull Context context) {
+ mGoogleFitHealthProvider = new GoogleFitHealthProvider(context, this);
+ mSamsungHealthProvider = new SamsungHealthProvider(context);
+
+ // By default it will use Google Fit Health provider
+ mCurrentHealthProvider = mGoogleFitHealthProvider;
+ }
+
+ /**
+ * Get instance of Input Kit class.
+ *
+ * @param context current application context
+ * @return {@link InputKit}
+ */
+ public static InputKit getInstance(@NonNull Context context) {
+ if (sInputKit == null) sInputKit = new InputKit(context);
+ return sInputKit;
+ }
+
+ /**
+ * Set current host activity.
+ * Typically it will be used to show an alert dialog since it bound to the Activity
+ *
+ * @param activity Current Host activity
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void setHostActivity(@Nullable Activity activity) {
+ mCurrentHealthProvider.setHostActivity(activity);
+ }
+
+ /**
+ * Set priority health provider
+ * @param healthProvider available health provider
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void setHealthProvider(@NonNull ProviderType healthProvider) {
+ switch (healthProvider) {
+ case GOOGLE_FIT: mCurrentHealthProvider = mGoogleFitHealthProvider;
+ break;
+ case SAMSUNG_HEALTH:
+ mCurrentHealthProvider = mSamsungHealthProvider;
+ break;
+ case GARMIN_SDK:
+ default:
+ mCurrentHealthProvider = mGoogleFitHealthProvider;
+ break;
+ }
+ }
+
+ /**
+ * Authorize Input Kit service connections.
+ * @param callback event listener
+ * @param permissionType permission type. in case specific handler required when asking input kit
+ * type.
+ *
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void authorize(@NonNull Callback callback, String... permissionType) {
+ mCurrentHealthProvider.authorize(callback, permissionType);
+ }
+
+ /**
+ * Disconnect from current Health provider
+ * @param callback {@link Result} event listener
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void disconnectCurrentHealthProvider(@NonNull Result callback) {
+ mCurrentHealthProvider.disconnect(callback);
+ }
+
+ /**
+ * Check health availability.
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public boolean isAvailable() {
+ return mCurrentHealthProvider.isAvailable();
+ }
+
+ /**
+ * Check authorised permission type availability.
+ * @param permissionType requested permission type
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public boolean isPermissionsAuthorised(String[] permissionType) {
+ return mCurrentHealthProvider.isPermissionsAuthorised(permissionType);
+ }
+
+ /**
+ * Get total distance of walk on specific time range.
+ *
+ * @param startTime epoch for the start date
+ * @param endTime epoch for the end date
+ * @param limit historical data limitation
+ * set to 0 if you want to calculate all available distance within specific range
+ * @param callback {@link Result } containing number of total distance
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void getDistance(long startTime, long endTime, int limit, @NonNull Result callback) {
+ mCurrentHealthProvider.getDistance(startTime, endTime, limit, callback);
+ }
+
+ /**
+ * Get sample distance of walk on specific time range.
+ * @param startTime epoch for the start date
+ * @param endTime epoch for the end date
+ * @param limit historical data limitation
+ * set to 0 if you want to calculate all available distance within specific range
+ * @param callback {@link Result} containing set of available distance
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void getDistanceSamples(long startTime,
+ long endTime,
+ int limit,
+ @NonNull Result>> callback) {
+ mCurrentHealthProvider.getDistanceSamples(startTime, endTime, limit, callback);
+ }
+
+ /**
+ * Get total Today steps count.
+ * @param callback {@link Result } containing number of total steps count
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void getStepCount(@NonNull Result callback) {
+ mCurrentHealthProvider.getStepCount(callback);
+ }
+
+ /**
+ * Get total steps count of specific range.
+ * @param startTime epoch for the start date
+ * @param endTime epoch for the end date
+ * @param limit historical data limitation
+ * set to 0 if you want to calculate all available distance within specific range
+ * @param callback {@link Result } containing number of total steps count
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void getStepCount(long startTime,
+ long endTime,
+ int limit,
+ @NonNull Result callback) {
+ mCurrentHealthProvider.getStepCount(startTime, endTime, limit, callback);
+ }
+
+ /**
+ * Get distribution step count history by specific time period.
+ * This function should be called within asynchronous process because of
+ * reading historical data through {@link com.google.android.gms.fitness.Fitness#HistoryApi} will be executed on main
+ * thread by default.
+ *
+ * @param startTime epoch for the start date
+ * @param endTime epoch for the end date
+ * @param interval on of any {@link nl.sense.rninputkit.inputkit.constant.Interval.IntervalName}
+ * @param limit historical data limitation
+ * set to null if you want to calculate all available step count within specific range
+ * @param callback {@link Result } containing a set of history step content
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void getStepCountDistribution(long startTime,
+ long endTime,
+ @NonNull @Interval.IntervalName String interval,
+ int limit,
+ @NonNull Result callback) {
+ mCurrentHealthProvider.getStepCountDistribution(startTime, endTime, interval, limit, callback);
+ }
+
+ /**
+ * Returns data contains sleep analysis data of a specific range. Sorted recent data first.
+ * @param startTime epoch for the start date of the range
+ * @param endTime epoch for the end date of the range
+ * @param callback {@link Result} containing a set of sleep analysis samples
+ */
+ public void getSleepAnalysisSamples(long startTime, long endTime,
+ @NonNull InputKit.Result>> callback) {
+ mCurrentHealthProvider.getSleepAnalysisSamples(startTime, endTime, callback);
+ }
+
+ /**
+ * Get blood pressure history
+ * @param startTime epoch for the start date of the range
+ * @param endTime epoch for the end date of the range
+ * @param callback {@link Result} containing a set history of user blood pressure
+ */
+ public void getBloodPressure(long startTime, long endTime, @NonNull Result> callback) {
+ mCurrentHealthProvider.getBloodPressure(startTime, endTime, callback);
+ }
+
+ /**
+ * Get weight history
+ * @param startTime epoch for the start date of the range
+ * @param endTime epoch for the end date of the range
+ * @param callback {@link Result} containing a set history of user weight
+ */
+ public void getWeight(long startTime, long endTime, @NonNull Result> callback) {
+ mCurrentHealthProvider.getWeight(startTime, endTime, callback);
+ }
+
+ /* Start monitoring health sensors.
+ * @param sensorType sensor type should be one of these {@link SampleType.SampleName} sensor
+ * @param samplingRate sensor sampling rate.
+ * Sensor will be started every X-Time Unit, for instance : { 5, {@link TimeUnit#MINUTES} }.
+ * If sampling rate is unspecified it will be set to 10 minute interval.
+ * @param listener {@link SensorListener} sensor listener
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void startMonitoring(@NonNull String sensorType,
+ @NonNull Pair samplingRate,
+ @NonNull SensorListener listener) {
+ mCurrentHealthProvider.startMonitoring(sensorType, samplingRate, listener);
+ }
+
+ /**
+ * Stop monitoring health sensors.
+ * @param sensorType Sensor type should be one of these {@link SampleType.SampleName} sensor
+ * @param listener {@link SensorListener} sensor listener
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void stopMonitoring(@NonNull String sensorType,
+ @NonNull SensorListener listener) {
+ mCurrentHealthProvider.stopMonitoring(sensorType, listener);
+ }
+
+ /**
+ * Start tracking specific sensor.
+ *
+ * @param sensorType Sample type should be one of these {@link SampleType.SampleName} sensor
+ * @param samplingRate sensor sampling rate.
+ * Sensor will be started every X-Time Unit, for instance : { 5, {@link TimeUnit#MINUTES} }.
+ * If sampling rate is unspecified it will be set to 10 minute interval.
+ * @param listener {@link SensorListener} sensor listener
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void startTracking(@NonNull @SampleType.SampleName String sensorType,
+ @NonNull Pair samplingRate,
+ @NonNull SensorListener listener) {
+ mCurrentHealthProvider.startTracking(sensorType, samplingRate, listener);
+ }
+
+ /**
+ * Stop tracking specific sensor.
+ *
+ * @param sensorType Sample type should be one of these {@link SampleType.SampleName} sensor
+ * @param listener {@link SensorListener} sensor listener
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void stopTracking(@NonNull String sensorType,
+ @NonNull SensorListener listener) {
+ mCurrentHealthProvider.stopTracking(sensorType, listener);
+ }
+
+ /**
+ * Stop all tracking specific sensor.
+ *
+ * @param listener {@link SensorListener} sensor listener
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void stopTrackingAll(@NonNull SensorListener listener) {
+ mCurrentHealthProvider.stopTrackingAll(listener);
+ }
+
+ @Override
+ public void release() {
+ sInputKit = null;
+ mCurrentHealthProvider = null;
+ mGoogleFitHealthProvider = null;
+ mSamsungHealthProvider = null;
+ // TODO: Put another references which should be released.
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/Options.java b/android/src/main/java/nl/sense/rninputkit/inputkit/Options.java
new file mode 100644
index 0000000..21dfd40
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/Options.java
@@ -0,0 +1,151 @@
+package nl.sense.rninputkit.inputkit;
+
+import nl.sense.rninputkit.inputkit.constant.Interval;
+import nl.sense.rninputkit.inputkit.entity.TimeInterval;
+
+import static nl.sense.rninputkit.inputkit.Options.Validator.validateEndTime;
+import static nl.sense.rninputkit.inputkit.Options.Validator.validateStartTime;
+
+/**
+ * Created by panjiyudasetya on 6/19/17.
+ */
+
+public class Options {
+ private static final TimeInterval DEFAULT_TIME_INTERVAL = new TimeInterval(Interval.TEN_MINUTE);
+
+ private Long startTime;
+ private Long endTime;
+ private boolean useDataAggregation;
+ private TimeInterval timeInterval;
+ private Integer limitation;
+
+ private Options(Long startTime,
+ Long endTime,
+ boolean useDataAggregation,
+ TimeInterval timeInterval,
+ Integer limitation) {
+ this.startTime = startTime;
+ this.endTime = endTime;
+ this.useDataAggregation = useDataAggregation;
+ this.timeInterval = timeInterval;
+ this.limitation = limitation;
+ }
+
+ public Long getStartTime() {
+ return startTime;
+ }
+
+ public Long getEndTime() {
+ return endTime;
+ }
+
+ public boolean isUseDataAggregation() {
+ return useDataAggregation;
+ }
+
+ public TimeInterval getTimeInterval() {
+ return timeInterval;
+ }
+
+ public Integer getLimitation() {
+ return limitation;
+ }
+
+ public static class Builder {
+ private Long newStartTime;
+ private Long newEndTime;
+ private boolean newUseDataAggregation;
+ private TimeInterval newTimeInterval;
+ private Integer newLimitation;
+
+ /**
+ * Set start time of steps history.
+ * @param startTime epoch
+ * @return Builder Options Builder
+ */
+ public Builder startTime(Long startTime) {
+ this.newStartTime = startTime;
+ return this;
+ }
+
+ /**
+ * Set end time of steps history.
+ * @param endTime epoch
+ * @return Builder Options Builder
+ */
+ public Builder endTime(Long endTime) {
+ this.newEndTime = endTime;
+ return this;
+ }
+
+ /**
+ * It will aggregating steps count data history by specific time and time unit.
+ * @return Builder Options Builder
+ */
+ public Builder useDataAggregation() {
+ this.newUseDataAggregation = true;
+ return this;
+ }
+
+ /**
+ * If {@link TimeInterval} not provided it will be set to {@link Options#DEFAULT_TIME_INTERVAL}.
+ * @param timeInterval time interval
+ * @return Builder Options Builder
+ */
+ public Builder timeInterval(TimeInterval timeInterval) {
+ this.newTimeInterval = timeInterval;
+ return this;
+ }
+
+ /**
+ * Set data limitation if required.
+ * @param limitation data limitation
+ * @return Builder Options Builder
+ */
+ public Builder limitation(Integer limitation) {
+ this.newLimitation = limitation;
+ return this;
+ }
+
+ public Options build() {
+ newStartTime = validateStartTime(newStartTime);
+ newEndTime = validateEndTime(newStartTime, newEndTime);
+
+ return new Options(newStartTime,
+ newEndTime,
+ newUseDataAggregation,
+ newTimeInterval == null ? DEFAULT_TIME_INTERVAL : newTimeInterval,
+ (newLimitation == null || newLimitation <= 0) ? null : newLimitation
+ );
+ }
+ }
+
+ static class Validator {
+ /**
+ * Validate start time value. If lower than 0, it will be set to 0
+ * @param startTime epoch
+ * @return valid start time
+ */
+ static long validateStartTime(Long startTime) {
+ if (startTime == null) throw new IllegalStateException("Start time should be defined!");
+ return startTime < 0 ? 0 : startTime;
+ }
+
+ /**
+ * Validate end time value. If end time lower than 0, it will be set to 0
+ *
+ * @param startTime epoch
+ * @param endTime epoch
+ * @return valid end time
+ * @throws IllegalStateException if end time lower than start time
+ */
+ static long validateEndTime(Long startTime, Long endTime) {
+ if (endTime == null) throw new IllegalStateException("End time should be defined!");
+
+ endTime = endTime < 0 ? 0 : endTime;
+
+ if (endTime < startTime) throw new IllegalStateException("End time cannot be lower than start time!");
+ return endTime;
+ }
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/constant/ApiPermissions.java b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/ApiPermissions.java
new file mode 100644
index 0000000..69bfe95
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/ApiPermissions.java
@@ -0,0 +1,17 @@
+package nl.sense.rninputkit.inputkit.constant;
+
+import static android.Manifest.permission.ACCESS_FINE_LOCATION;
+import static android.Manifest.permission.INTERNET;
+
+/**
+ * Created by panjiyudasetya on 7/5/17.
+ */
+
+public class ApiPermissions {
+ private ApiPermissions() { }
+
+ public static final String[] STEPS_API_PERMISSIONS = {
+ INTERNET,
+ ACCESS_FINE_LOCATION
+ };
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/constant/Constant.java b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/Constant.java
new file mode 100644
index 0000000..686cc8b
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/Constant.java
@@ -0,0 +1,11 @@
+package nl.sense.rninputkit.inputkit.constant;
+
+/**
+ * Created by panjiyudasetya on 10/17/17.
+ */
+
+public class Constant {
+ private Constant() { }
+ public static final String MONITORED_HEALTH_SENSORS = "MONITORED_HEALTH_SENSORS";
+ public static final String TRACKED_HEALTH_SENSORS = "TRACKED_HEALTH_SENSORS";
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/constant/DataSampling.java b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/DataSampling.java
new file mode 100644
index 0000000..a90a3ea
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/DataSampling.java
@@ -0,0 +1,13 @@
+package nl.sense.rninputkit.inputkit.constant;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Created by panjiyudasetya on 6/15/17.
+ */
+
+public class DataSampling {
+ private DataSampling() { }
+ public static final int DEFAULT_TIME_SAMPLING_RATE = 10;
+ public static final TimeUnit DEFAULT_SAMPLING_TIME_UNIT = TimeUnit.MINUTES;
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/constant/IKStatus.java b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/IKStatus.java
new file mode 100644
index 0000000..4112b48
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/IKStatus.java
@@ -0,0 +1,44 @@
+package nl.sense.rninputkit.inputkit.constant;
+
+/**
+ * Created by panjiyudasetya on 10/12/17.
+ */
+
+public class IKStatus {
+ private IKStatus() { }
+ public static final String INPUT_KIT_DISCONNECTED = "INPUT_KIT_DISCONNECTED";
+ public static final String INPUT_KIT_NOT_CONNECTED = "INPUT_KIT_NOT_CONNECTED";
+ public static final String INPUT_KIT_SERVICE_NOT_AVAILABLE = "INPUT_KIT_SERVICE_NOT_AVAILABLE";
+ public static final String REQUIRED_GOOGLE_FIT_APP = "REQUIRED_GOOGLE_FIT_APP";
+ public static final String INPUT_KIT_OUT_OF_DATE_PLAY_SERVICE = "INPUT_KIT_OUT_OF_DATE_PLAY_SERVICE";
+ public static final String INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS = "INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS";
+ public static final String INPUT_KIT_CONNECTION_ERROR = "INPUT_KIT_CONNECTION_ERROR";
+ public static final String INPUT_KIT_NO_DEVICES_SOURCE = "INPUT_KIT_NO_DEVICES_SOURCE";
+ public static final String INPUT_KIT_MONITOR_REGISTERED = "INPUT_KIT_MONITOR_ALREADY_REGISTERED";
+ public static final String INPUT_KIT_MONITOR_UNREGISTERED = "INPUT_KIT_MONITOR_UNREGISTERED";
+ public static final String INPUT_KIT_MONITORING_NOT_AVAILABLE = "INPUT_KIT_MONITORING_NOT_AVAILABLE";
+ public static final String INPUT_KIT_UNREACHABLE_CONTEXT =
+ String.format("UNREACHABLE_APPLICATION_CONTEXT \n%s %s",
+ "Context was no longer maintained in memory, ",
+ "you might need to re-initiate InputKit instance before use any apis.");
+ public static final String SAMSUNG_HEALTH_IS_NOT_AVAILABLE = "SAMSUNG_HEALTH_IS_NOT_AVAILABLE";
+ public static final String SAMSUNG_HEALTH_NOT_INSTALLED = "SAMSUNG_HEALTH_NOT_INSTALLED";
+ public static final String SAMSUNG_HEALTH_OLD_VERSION = "SAMSUNG_HEALTH_OLD_VERSION";
+ public static final String SAMSUNG_HEALTH_DISABLED = "SAMSUNG_HEALTH_DISABLED";
+ public static final String SAMSUNG_HEALTH_USER_AGREEMENT_NEEDED = "SAMSUNG_HEALTH_USER_AGREEMENT_NEEDED";
+
+ public abstract class Code {
+ public static final int VALID_REQUEST = 0;
+ public static final int UNKNOWN_ERROR = -99;
+ public static final int IK_NOT_CONNECTED = -3;
+ public static final int IK_NOT_AVAILABLE = -4;
+ public static final int GOOGLE_FIT_REQUIRED = -5;
+ public static final int OUT_OF_DATE_PLAY_SERVICE = -6;
+ public static final int INVALID_REQUEST = -7;
+ public static final int REQUIRED_GRANTED_PERMISSIONS = -8;
+
+ public static final int S_HEALTH_PERMISSION_REQUIRED = -9;
+ public static final int S_HEALTH_DISCONNECTED = -10;
+ public static final int S_HEALTH_CONNECTION_ERROR = -11;
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/constant/Interval.java b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/Interval.java
new file mode 100644
index 0000000..ffb3d38
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/Interval.java
@@ -0,0 +1,31 @@
+package nl.sense.rninputkit.inputkit.constant;
+
+import androidx.annotation.StringDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Created by panjiyudasetya on 6/19/17.
+ */
+
+public class Interval {
+ private Interval() { }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({
+ ONE_WEEK,
+ ONE_DAY,
+ AN_HOUR,
+ HALF_HOUR,
+ TEN_MINUTE,
+ ONE_MINUTE
+ })
+ public @interface IntervalName { }
+ public static final String ONE_WEEK = "week";
+ public static final String ONE_DAY = "day";
+ public static final String AN_HOUR = "hour";
+ public static final String HALF_HOUR = "halfHour";
+ public static final String TEN_MINUTE = "tenMinute";
+ public static final String ONE_MINUTE = "oneMinute";
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/constant/RequiredApp.java b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/RequiredApp.java
new file mode 100644
index 0000000..dfe5065
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/RequiredApp.java
@@ -0,0 +1,12 @@
+package nl.sense.rninputkit.inputkit.constant;
+
+/**
+ * Created by panjiyudasetya on 9/26/17.
+ */
+
+public class RequiredApp {
+ private RequiredApp() { }
+ public static final String GOOGLE_FIT_PACKAGE_NAME = "com.google.android.apps.fitness";
+ public static final String PLAY_SERVICE_PACKAGE_NAME = "com.google.android.gms";
+ public static final String SAMSUNG_HEALTH_PACKAGE_NAME = "com.sec.android.app.shealth";
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/constant/SampleType.java b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/SampleType.java
new file mode 100644
index 0000000..2803c96
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/constant/SampleType.java
@@ -0,0 +1,42 @@
+package nl.sense.rninputkit.inputkit.constant;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * This is a constants value to define request read permissions for the given SampleType(s).
+ *
+ * Created by panjiyudasetya on 6/19/17.
+ */
+
+public class SampleType {
+ private SampleType() { }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({
+ SLEEP,
+ STEP_COUNT,
+ DISTANCE_WALKING_RUNNING,
+ WEIGHT,
+ BLOOD_PRESSURE
+ })
+ public @interface SampleName { }
+ public static final String SLEEP = "sleep";
+ public static final String STEP_COUNT = "stepCount";
+ public static final String DISTANCE_WALKING_RUNNING = "distanceWalkingRunning";
+ public static final String WEIGHT = "weight";
+ public static final String BLOOD_PRESSURE = "bloodPressure";
+ public static final String UNAVAILABLE = "unavailable";
+
+ public static String checkFitSampleType(@NonNull String sampleType) {
+ // Sleep is not supported by GoogleFit at this moment
+ if (sampleType.equals(STEP_COUNT) || sampleType.equals(DISTANCE_WALKING_RUNNING)
+ || sampleType.equals(WEIGHT)) {
+ return sampleType;
+ }
+ return UNAVAILABLE;
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/entity/BloodPressure.java b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/BloodPressure.java
new file mode 100644
index 0000000..344b5e3
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/BloodPressure.java
@@ -0,0 +1,94 @@
+package nl.sense.rninputkit.inputkit.entity;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.Expose;
+
+/**
+ * Created by xedi on 10/9/17.
+ */
+
+public class BloodPressure {
+ private static final Gson GSON = new Gson();
+ @Expose
+ private Integer systolic;
+ @Expose
+ private Integer diastolic;
+ @Expose
+ private Float mean;
+ @Expose
+ private Integer pulse;
+ @Expose
+ private String comment;
+ @Expose
+ private DateContent timeRecord;
+
+ public BloodPressure(Integer sys, Integer dia, Long time) {
+ this.systolic = sys;
+ this.diastolic = dia;
+ this.timeRecord = new DateContent(time);
+ }
+
+ public Integer getSystolic() {
+ return systolic;
+ }
+
+ public void setSystolic(Integer systolic) {
+ this.systolic = systolic;
+ }
+
+ public Integer getDiastolic() {
+ return diastolic;
+ }
+
+ public void setDiastolic(Integer diastolic) {
+ this.diastolic = diastolic;
+ }
+
+ public Float getMean() {
+ return mean;
+ }
+
+ public void setMean(Float mean) {
+ this.mean = mean;
+ }
+
+ public Integer getPulse() {
+ return pulse;
+ }
+
+ public void setPulse(Integer pulse) {
+ this.pulse = pulse;
+ }
+
+ public String getComment() {
+ return comment;
+ }
+
+ public void setComment(String comment) {
+ this.comment = comment;
+ }
+
+ public DateContent getTimeRecord() {
+ return timeRecord;
+ }
+
+ public void setTimeRecord(DateContent timeRecord) {
+ this.timeRecord = timeRecord;
+ }
+
+ public String toJson() {
+ return GSON.toJson(this);
+ }
+
+ @Override
+ public String toString() {
+ return "{"
+ + "time: " + timeRecord.getString()
+ + "\nsystolic=" + systolic
+ + "\n, diastolic=" + diastolic
+ + "\n, mean=" + mean
+ + "\n, pulse=" + pulse
+ + "\n, comment=" + comment
+ + "}";
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/entity/DateContent.java b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/DateContent.java
new file mode 100644
index 0000000..a6ea09a
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/DateContent.java
@@ -0,0 +1,61 @@
+package nl.sense.rninputkit.inputkit.entity;
+
+import com.google.gson.annotations.Expose;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * Created by panjiyudasetya on 7/6/17.
+ */
+
+public class DateContent {
+ private static final String STR_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss Z";
+ private static final DateFormat DATE_FORMATTER = new SimpleDateFormat(STR_DATE_FORMAT, Locale.US);
+ @Expose
+ private long epoch;
+ @Expose
+ private String string;
+
+ public DateContent(long epoch) {
+ this.epoch = epoch;
+ this.string = DATE_FORMATTER.format(new Date(epoch));
+ }
+
+ public long getEpoch() {
+ return epoch;
+ }
+
+ public String getString() {
+ return string;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof DateContent)) return false;
+
+ DateContent that = (DateContent) o;
+
+ if (epoch != that.epoch) return false;
+ return string != null ? string.equals(that.string) : that.string == null;
+
+ }
+
+ @Override
+ public int hashCode() {
+ int result = (int) (epoch ^ (epoch >>> 32));
+ result = 31 * result + (string != null ? string.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "DateContent{"
+ + "epoch=" + epoch
+ + ", string='" + string + '\''
+ + '}';
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/entity/IKValue.java b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/IKValue.java
new file mode 100644
index 0000000..acda51f
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/IKValue.java
@@ -0,0 +1,116 @@
+package nl.sense.rninputkit.inputkit.entity;
+
+import androidx.annotation.NonNull;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.annotations.Expose;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Created by panjiyudasetya on 10/23/17.
+ */
+
+public class IKValue {
+ protected static final Gson GSON = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create();
+ @Expose
+ protected T value;
+ @Expose
+ protected DateContent startDate;
+ @Expose
+ protected DateContent endDate;
+ @Expose(serialize = false)
+ private boolean flagOverlap;
+
+ public IKValue(T value) {
+ this.value = value;
+ }
+
+ public IKValue(@NonNull T value,
+ @NonNull DateContent startDate,
+ @NonNull DateContent endDate) {
+ this.value = value;
+ this.startDate = startDate;
+ this.endDate = endDate;
+ }
+
+ public IKValue(@NonNull DateContent startDate,
+ @NonNull DateContent endDate) {
+ this.startDate = startDate;
+ this.endDate = endDate;
+ }
+
+ public T getValue() {
+ return value;
+ }
+
+ public void setValue(T value) {
+ this.value = value;
+ }
+
+ public DateContent getStartDate() {
+ return startDate;
+ }
+
+ public DateContent getEndDate() {
+ return endDate;
+ }
+
+ public boolean isFlaggedOverlap() {
+ return flagOverlap;
+ }
+
+ public void setFlagOverlap(boolean flagOverlap) {
+ this.flagOverlap = flagOverlap;
+ }
+
+ public static int getTotalIntegers(List> values) {
+ if (values == null || values.isEmpty()) return 0;
+ int total = 0;
+ for (IKValue value : values) {
+ total += value.getValue();
+ }
+ return total;
+ }
+
+ public static float getTotalFloats(List> values) {
+ if (values == null || values.isEmpty()) return 0;
+ float total = 0;
+ for (IKValue value : values) {
+ total += value.getValue();
+ }
+ return total;
+ }
+
+ public String toJson() {
+ return GSON.toJson(this);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ IKValue> ikValue = (IKValue>) o;
+ return flagOverlap == ikValue.flagOverlap
+ && Objects.equals(value, ikValue.value)
+ && Objects.equals(startDate, ikValue.startDate)
+ && Objects.equals(endDate, ikValue.endDate);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(value, startDate, endDate, flagOverlap);
+ }
+
+ @Override
+ public String toString() {
+ return "IKValue{"
+ + "value=" + value
+ + ", startDate=" + startDate
+ + ", endDate=" + endDate
+ + ", flagOverlap=" + flagOverlap
+ + '}';
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/entity/SensorDataPoint.java b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/SensorDataPoint.java
new file mode 100644
index 0000000..5edb2a4
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/SensorDataPoint.java
@@ -0,0 +1,36 @@
+package nl.sense.rninputkit.inputkit.entity;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+/**
+ * Created by panjiyudasetya on 10/20/17.
+ */
+
+public class SensorDataPoint {
+ public String topic;
+ public List> payload;
+
+ public SensorDataPoint(@NonNull String topic,
+ @NonNull List> payload) {
+ this.topic = topic;
+ this.payload = payload;
+ }
+
+ public String getTopic() {
+ return topic;
+ }
+
+ public void setTopic(String topic) {
+ this.topic = topic;
+ }
+
+ public List> getPayload() {
+ return payload;
+ }
+
+ public void setPayload(List> payload) {
+ this.payload = payload;
+ }
+}
\ No newline at end of file
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/entity/Step.java b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/Step.java
new file mode 100644
index 0000000..acebfa7
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/Step.java
@@ -0,0 +1,11 @@
+package nl.sense.rninputkit.inputkit.entity;
+
+/**
+ * Created by panjiyudasetya on 6/15/17.
+ */
+
+public class Step extends IKValue {
+ public Step(int value, long startDate, long endDate) {
+ super(value, new DateContent(startDate), new DateContent(endDate));
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/entity/StepContent.java b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/StepContent.java
new file mode 100644
index 0000000..85d2203
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/StepContent.java
@@ -0,0 +1,52 @@
+package nl.sense.rninputkit.inputkit.entity;
+
+import androidx.annotation.NonNull;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.annotations.Expose;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Created by panjiyudasetya on 6/15/17.
+ */
+
+public class StepContent extends IKValue> {
+ private static final Gson GSON = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create();
+ @Expose(serialize = false)
+ private boolean isQueryOk;
+
+ public StepContent(boolean isQueryOk,
+ long startDate,
+ long endDate,
+ @NonNull List steps) {
+ super(steps, new DateContent(startDate), new DateContent(endDate));
+ this.isQueryOk = isQueryOk;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ if (!super.equals(o)) return false;
+ StepContent that = (StepContent) o;
+ return isQueryOk == that.isQueryOk;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), isQueryOk);
+ }
+
+ @Override
+ public String toString() {
+ return "StepContent{"
+ + "isQueryOk=" + isQueryOk
+ + ", value=" + value
+ + ", startDate=" + startDate
+ + ", endDate=" + endDate
+ + '}';
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/entity/TimeInterval.java b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/TimeInterval.java
new file mode 100644
index 0000000..f36ed74
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/TimeInterval.java
@@ -0,0 +1,87 @@
+package nl.sense.rninputkit.inputkit.entity;
+
+import androidx.annotation.NonNull;
+
+import java.util.concurrent.TimeUnit;
+
+import nl.sense.rninputkit.inputkit.constant.Interval;
+
+import static nl.sense.rninputkit.inputkit.constant.Interval.ONE_DAY;
+import static nl.sense.rninputkit.inputkit.constant.Interval.HALF_HOUR;
+import static nl.sense.rninputkit.inputkit.constant.Interval.AN_HOUR;
+import static nl.sense.rninputkit.inputkit.constant.Interval.ONE_MINUTE;
+import static nl.sense.rninputkit.inputkit.constant.Interval.TEN_MINUTE;
+import static nl.sense.rninputkit.inputkit.constant.Interval.ONE_WEEK;
+
+/**
+ * Created by panjiyudasetya on 6/19/17.
+ */
+
+public class TimeInterval {
+ private int mValue;
+ private TimeUnit mTimeUnit;
+
+ public TimeInterval(@NonNull @Interval.IntervalName String type) {
+ setValue(type);
+ }
+
+ public int getValue() {
+ return mValue;
+ }
+
+ public TimeUnit getTimeUnit() {
+ return mTimeUnit;
+ }
+
+ private void setValue(@Interval.IntervalName String type) {
+ if (type.equals(ONE_WEEK)) {
+ mValue = 7;
+ mTimeUnit = TimeUnit.DAYS;
+ } else if (type.equals(ONE_DAY)) {
+ mValue = 1;
+ mTimeUnit = TimeUnit.DAYS;
+ } else if (type.equals(AN_HOUR)) {
+ mValue = 1;
+ mTimeUnit = TimeUnit.HOURS;
+ } else if (type.equals(HALF_HOUR)) {
+ mValue = 30;
+ mTimeUnit = TimeUnit.MINUTES;
+ } else if (type.equals(TEN_MINUTE)) {
+ mValue = 10;
+ mTimeUnit = TimeUnit.MINUTES;
+ } else if (type.equals(ONE_MINUTE)) {
+ mValue = 1;
+ mTimeUnit = TimeUnit.MINUTES;
+ } else {
+ mValue = 1;
+ mTimeUnit = TimeUnit.DAYS;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "TimeInterval{"
+ + "value=" + mValue
+ + ", timeUnit=" + mTimeUnit
+ + '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof TimeInterval)) return false;
+
+ TimeInterval that = (TimeInterval) o;
+
+ if (mValue != that.mValue) return false;
+ return mTimeUnit == that.mTimeUnit;
+
+ }
+
+ @Override
+ public int hashCode() {
+ int result = mValue;
+ result = 31 * result + (mTimeUnit != null ? mTimeUnit.hashCode() : 0);
+ return result;
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/entity/Weight.java b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/Weight.java
new file mode 100644
index 0000000..50170d0
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/entity/Weight.java
@@ -0,0 +1,72 @@
+package nl.sense.rninputkit.inputkit.entity;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.Expose;
+
+/**
+ * Created by xedi on 10/9/17.
+ */
+
+public class Weight {
+ private static final Gson GSON = new Gson();
+ @Expose
+ private DateContent timeRecord;
+ @Expose
+ private Float weight;
+ @Expose
+ private Integer bodyFat;
+ @Expose
+ private String comment;
+
+ public Weight(Float weight, Integer bodyFat, long time) {
+ this.weight = weight;
+ this.bodyFat = bodyFat;
+ this.timeRecord = new DateContent(time);
+ }
+
+ public DateContent getTimeRecorded() {
+ return timeRecord;
+ }
+
+ public void setTimeRecorded(DateContent timeRecord) {
+ this.timeRecord = timeRecord;
+ }
+
+ public Float getWeight() {
+ return weight;
+ }
+
+ public void setWeight(Float weight) {
+ this.weight = weight;
+ }
+
+ public Integer getBodyFat() {
+ return bodyFat;
+ }
+
+ public void setBodyFat(Integer bodyFat) {
+ this.bodyFat = bodyFat;
+ }
+
+ public String getComment() {
+ return comment;
+ }
+
+ public void setComment(String comment) {
+ this.comment = comment;
+ }
+
+ public String toJson() {
+ return GSON.toJson(this);
+ }
+
+ @Override
+ public String toString() {
+ return "{"
+ + "time: " + timeRecord.getString()
+ + "\nweight=" + weight
+ + "\n, bodyFat=" + bodyFat
+ + "\n, comment=" + comment
+ + "}";
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/FitPermissionSet.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/FitPermissionSet.java
new file mode 100644
index 0000000..90f733d
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/FitPermissionSet.java
@@ -0,0 +1,67 @@
+package nl.sense.rninputkit.inputkit.googlefit;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.android.gms.fitness.FitnessOptions;
+import com.google.android.gms.fitness.data.DataType;
+
+import nl.sense.rninputkit.inputkit.constant.SampleType;
+
+public class FitPermissionSet {
+ private static FitPermissionSet sPermissionSet;
+
+ FitPermissionSet() { }
+
+ public static FitPermissionSet getInstance() {
+ if (sPermissionSet == null) {
+ sPermissionSet = new FitPermissionSet();
+ }
+ return sPermissionSet;
+ }
+
+ public FitnessOptions getPermissionsSet(@Nullable String[] sampleTypes) {
+ FitnessOptions.Builder builder = FitnessOptions.builder();
+ if (sampleTypes != null && sampleTypes.length > 0) {
+ for (String sampleType : sampleTypes) {
+ createFitnessOptions(sampleType, builder);
+ }
+ }
+ return builder.build();
+ }
+
+ private void createFitnessOptions(@NonNull String sampleType,
+ @NonNull FitnessOptions.Builder builder) {
+ if (sampleType.equals(SampleType.STEP_COUNT)) {
+ builder.addDataType(
+ DataType.TYPE_STEP_COUNT_DELTA,
+ FitnessOptions.ACCESS_READ
+ ).addDataType(
+ DataType.AGGREGATE_STEP_COUNT_DELTA,
+ FitnessOptions.ACCESS_READ
+ );
+ return;
+ }
+
+ if (sampleType.equals(SampleType.DISTANCE_WALKING_RUNNING)) {
+ builder.addDataType(
+ DataType.TYPE_DISTANCE_DELTA,
+ FitnessOptions.ACCESS_READ
+ ).addDataType(
+ DataType.AGGREGATE_DISTANCE_DELTA,
+ FitnessOptions.ACCESS_READ
+ );
+ return;
+ }
+
+ if (sampleType.equals(SampleType.WEIGHT)) {
+ builder.addDataType(
+ DataType.TYPE_WEIGHT,
+ FitnessOptions.ACCESS_READ
+ ).addDataType(
+ DataType.AGGREGATE_WEIGHT_SUMMARY,
+ FitnessOptions.ACCESS_READ
+ );
+ }
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/GoogleFitHealthProvider.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/GoogleFitHealthProvider.java
new file mode 100644
index 0000000..86059f9
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/GoogleFitHealthProvider.java
@@ -0,0 +1,744 @@
+package nl.sense.rninputkit.inputkit.googlefit;
+
+import android.content.Context;
+import android.content.Intent;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Log;
+import android.util.Pair;
+
+import com.google.android.gms.auth.api.signin.GoogleSignIn;
+import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
+import com.google.android.gms.auth.api.signin.GoogleSignInClient;
+import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
+import com.google.android.gms.fitness.Fitness;
+import com.google.android.gms.fitness.FitnessOptions;
+import com.google.android.gms.fitness.request.DataReadRequest;
+import com.google.android.gms.tasks.Continuation;
+import com.google.android.gms.tasks.OnCompleteListener;
+import com.google.android.gms.tasks.Task;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nl.sense.rninputkit.inputkit.HealthProvider;
+import nl.sense.rninputkit.inputkit.HealthTrackerState;
+import nl.sense.rninputkit.inputkit.InputKit.Callback;
+import nl.sense.rninputkit.inputkit.InputKit.Result;
+import nl.sense.rninputkit.inputkit.Options;
+import nl.sense.rninputkit.inputkit.constant.Constant;
+import nl.sense.rninputkit.inputkit.constant.IKStatus;
+import nl.sense.rninputkit.inputkit.constant.Interval;
+import nl.sense.rninputkit.inputkit.constant.SampleType;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+import nl.sense.rninputkit.inputkit.entity.SensorDataPoint;
+import nl.sense.rninputkit.inputkit.entity.StepContent;
+import nl.sense.rninputkit.inputkit.entity.TimeInterval;
+import nl.sense.rninputkit.inputkit.googlefit.history.FitHistory;
+import nl.sense.rninputkit.inputkit.googlefit.sensor.SensorManager;
+import nl.sense.rninputkit.inputkit.helper.AppHelper;
+import nl.sense.rninputkit.inputkit.helper.InputKitTimeUtils;
+import nl.sense.rninputkit.inputkit.status.IKProviderInfo;
+import nl.sense.rninputkit.inputkit.status.IKResultInfo;
+
+import static nl.sense.rninputkit.inputkit.constant.IKStatus.Code.IK_NOT_AVAILABLE;
+
+/**
+ * Created by panjiyudasetya on 10/13/17.
+ */
+
+public class GoogleFitHealthProvider extends HealthProvider {
+ public static final int GF_PERMISSION_REQUEST_CODE = 77;
+ private static final IKResultInfo OUT_OF_DATE_PLAY_SERVICE = new IKResultInfo(
+ IKStatus.Code.OUT_OF_DATE_PLAY_SERVICE,
+ IKStatus.INPUT_KIT_OUT_OF_DATE_PLAY_SERVICE
+ );
+ private static final IKResultInfo REQUIRED_GOOGLE_FIT_APP = new IKResultInfo(
+ IKStatus.Code.GOOGLE_FIT_REQUIRED,
+ IKStatus.REQUIRED_GOOGLE_FIT_APP
+ );
+ private static final String TAG = GoogleFitHealthProvider.class.getSimpleName();
+ private FitHistory mFitHistory;
+ private SensorManager mSensorMonitoring;
+ private SensorManager mSensorTracking;
+
+ public GoogleFitHealthProvider(@NonNull Context context) {
+ super(context);
+ init(context);
+ }
+
+ public GoogleFitHealthProvider(@NonNull Context context, @NonNull IReleasableHostProvider releasableHost) {
+ super(context, releasableHost);
+ init(context);
+ }
+
+ private void init(@NonNull Context context) {
+ mFitHistory = new FitHistory(context);
+ mSensorMonitoring = new SensorManager(context);
+ mSensorTracking = new SensorManager(context);
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return getContext() != null && GoogleSignIn.hasPermissions(GoogleSignIn.getLastSignedInAccount(getContext()));
+ }
+
+ @Override
+ public boolean isPermissionsAuthorised(String[] permissionTypes) {
+ if (getContext() != null && permissionTypes != null && permissionTypes.length > 0) {
+ FitnessOptions options = FitPermissionSet.getInstance().getPermissionsSet(permissionTypes);
+ return GoogleSignIn.hasPermissions(
+ GoogleSignIn.getLastSignedInAccount(getContext()),
+ options);
+ }
+ return isAvailable();
+ }
+
+ @Override
+ public void authorize(@NonNull final Callback callback, String... permissionType) {
+ Context context = getContext();
+ if (context == null) {
+ onUnreachableContext(callback);
+ return;
+ }
+
+ if (!AppHelper.isPlayServiceUpToDate(context)) {
+ callback.onNotAvailable(OUT_OF_DATE_PLAY_SERVICE);
+ return;
+ }
+
+ if (!AppHelper.isGoogleFitInstalled(context)) {
+ callback.onNotAvailable(REQUIRED_GOOGLE_FIT_APP);
+ return;
+ }
+
+ if (!isPermissionsAuthorised(permissionType)) {
+ if (getHostActivity() == null) {
+ onUnreachableContext(callback);
+ return;
+ }
+
+ startSignedInAndAskForPermission(permissionType);
+
+ callback.onConnectionRefused(new IKProviderInfo(
+ IKStatus.Code.REQUIRED_GRANTED_PERMISSIONS,
+ IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS));
+ return;
+ }
+
+ callback.onAvailable("CONNECTED_TO_GOOGLE_FIT");
+ }
+
+ @Override
+ public void disconnect(@NonNull final Result callback) {
+ final Context context = getContext();
+ if (isInvalidContext(context, callback)) return;
+ if (!isAvailable(callback)) return;
+
+ assert context != null;
+ final GoogleSignInAccount account = GoogleSignIn.getLastSignedInAccount(context);
+ if (account != null) {
+ // Disconnect from Fit App and revoke existing permission access.
+ Fitness.getConfigClient(context, account).disableFit()
+ .continueWithTask(new Continuation>() {
+ @Override
+ public Task then(@NonNull Task task) {
+ return GoogleSignIn.getClient(context, getOptions())
+ .revokeAccess();
+ }
+ })
+ .addOnCompleteListener(new OnCompleteListener() {
+ @Override
+ public void onComplete(@NonNull Task task) {
+ callback.onNewData(true);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void getDistance(final long startTime,
+ final long endTime,
+ final int limit,
+ @NonNull final Result callback) {
+ if (isInvalidContext(getContext(), callback)) return;
+ if (!isAvailable(callback)) return;
+ if (!InputKitTimeUtils.validateTimeInput(startTime, endTime, callback)) return;
+
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ Options.Builder builder = new Options.Builder()
+ .startTime(startTime)
+ .endTime(endTime)
+ .limitation(limit <= 0 ? DataReadRequest.NO_LIMIT : limit);
+ // Guard the aggregation data. If limit is not specified then we need to use
+ // data aggregation to optimize query performance.
+ if (limit <= 0) builder.useDataAggregation();
+ Options options = builder.build();
+ mFitHistory.getDistance(options, callback);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, SampleType.DISTANCE_WALKING_RUNNING);
+ }
+
+ @Override
+ public void getDistanceSamples(final long startTime,
+ final long endTime,
+ final int limit,
+ @NonNull final Result>> callback) {
+ if (isInvalidContext(getContext(), callback)) return;
+ if (!isAvailable(callback)) return;
+ if (!InputKitTimeUtils.validateTimeInput(startTime, endTime, callback)) return;
+
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ Options.Builder builder = new Options.Builder()
+ .startTime(startTime)
+ .endTime(endTime)
+ .limitation(limit <= 0 ? DataReadRequest.NO_LIMIT : limit);
+ // Guard the aggregation data. If limit is not specified then we need to use
+ // data aggregation to optimize query performance.
+ if (limit <= 0) builder.useDataAggregation();
+ Options options = builder.build();
+ mFitHistory.getDistanceSamples(options, callback);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, SampleType.DISTANCE_WALKING_RUNNING);
+ }
+
+ @Override
+ public void getStepCount(@NonNull final Result callback) {
+ Context context = getContext();
+ if (isInvalidContext(context, callback)) return;
+ if (!isAvailable(callback)) return;
+
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ mFitHistory.getStepCount(callback);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, SampleType.STEP_COUNT);
+ }
+
+ @Override
+ public void getStepCount(final long startTime,
+ final long endTime,
+ final int limit,
+ @NonNull final Result callback) {
+ if (isInvalidContext(getContext(), callback)) return;
+ if (!isAvailable(callback)) return;
+ if (!InputKitTimeUtils.validateTimeInput(startTime, endTime, callback)) return;
+
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ Options.Builder builder = new Options.Builder()
+ .startTime(startTime)
+ .endTime(endTime)
+ .limitation(limit <= 0 ? DataReadRequest.NO_LIMIT : limit);
+ // Guard the aggregation data. If limit is not specified then we need to use
+ // data aggregation to optimize query performance.
+ if (limit <= 0) builder.useDataAggregation();
+ Options options = builder.build();
+ mFitHistory.getStepCount(options, callback);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, SampleType.STEP_COUNT);
+ }
+
+ @Override
+ public void getStepCountDistribution(final long startTime,
+ final long endTime,
+ @NonNull @Interval.IntervalName final String interval,
+ final int limit,
+ @NonNull final Result callback) {
+ if (isInvalidContext(getContext(), callback)) return;
+ if (!isAvailable(callback)) return;
+ if (!InputKitTimeUtils.validateTimeInput(startTime, endTime, callback)) return;
+
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ TimeInterval timeInterval = new TimeInterval(interval);
+ Options.Builder builder = new Options.Builder()
+ .startTime(startTime)
+ .endTime(endTime)
+ .timeInterval(timeInterval)
+ .limitation(limit <= 0 ? DataReadRequest.NO_LIMIT : limit);
+ // Guard the aggregation data. If limit is not specified then we need to use
+ // data aggregation to optimize query performance.
+ if (limit <= 0) builder.useDataAggregation();
+ Options options = builder.build();
+ mFitHistory.getStepCountDistribution(options, callback);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, SampleType.STEP_COUNT);
+ }
+
+ @Override
+ public void getSleepAnalysisSamples(long startTime, long endTime, @NonNull Result callback) {
+ callback.onError(new IKResultInfo(IK_NOT_AVAILABLE, IKStatus.INPUT_KIT_SERVICE_NOT_AVAILABLE));
+ }
+
+ @Override
+ public void getBloodPressure(long startTime, long endTime, @NonNull Result callback) {
+ // TODO: Implement Google Fit API to get blood pressure data from GF
+ callback.onError(new IKResultInfo(IK_NOT_AVAILABLE, IKStatus.INPUT_KIT_SERVICE_NOT_AVAILABLE));
+ }
+
+ @Override
+ public void getWeight(long startTime, long endTime, @NonNull Result callback) {
+ // TODO: Implement Google Fit API to get weight data from GF
+ callback.onError(new IKResultInfo(IK_NOT_AVAILABLE, IKStatus.INPUT_KIT_SERVICE_NOT_AVAILABLE));
+ }
+
+ @Override
+ public void startMonitoring(@NonNull @SampleType.SampleName final String sensorType,
+ @NonNull final Pair samplingRate,
+ @NonNull final SensorListener listener) {
+ final Context context = getContext();
+ if (isInvalidContext(context, listener, true)) return;
+ if (isSensorTypeUnavailable(sensorType, listener)) return;
+
+ assert context != null;
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ mSensorMonitoring.registerListener(sensorType, new SensorListener() {
+ @Override
+ public void onSubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.save(
+ context,
+ Constant.MONITORED_HEALTH_SENSORS,
+ Pair.create(sensorType, true)
+ );
+ }
+ listener.onSubscribe(info);
+ }
+
+ @Override
+ public void onReceive(@NonNull SensorDataPoint data) {
+ listener.onReceive(data);
+ }
+
+ @Override
+ public void onUnsubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.save(
+ context,
+ Constant.MONITORED_HEALTH_SENSORS,
+ Pair.create(sensorType, false)
+ );
+ }
+ listener.onUnsubscribe(info);
+ }
+ });
+ mSensorMonitoring.startTracking(sensorType, samplingRate);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ listener.onSubscribe(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, sensorType);
+ }
+
+ @Override
+ public void stopMonitoring(@NonNull @SampleType.SampleName final String sensorType,
+ @NonNull final SensorListener listener) {
+ final Context context = getContext();
+ if (isInvalidContext(context, listener, false)) return;
+ if (isSensorTypeUnavailable(sensorType, listener)) return;
+
+ assert context != null;
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ mSensorMonitoring.registerListener(sensorType, new SensorListener() {
+ @Override
+ public void onSubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.save(
+ context,
+ Constant.MONITORED_HEALTH_SENSORS,
+ Pair.create(sensorType, true)
+ );
+ }
+ listener.onSubscribe(info);
+ }
+
+ @Override
+ public void onReceive(@NonNull SensorDataPoint data) {
+ listener.onReceive(data);
+ }
+
+ @Override
+ public void onUnsubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.save(
+ context,
+ Constant.MONITORED_HEALTH_SENSORS,
+ Pair.create(sensorType, false)
+ );
+ }
+ listener.onUnsubscribe(info);
+ }
+ });
+ mSensorMonitoring.stopTracking(sensorType);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ listener.onUnsubscribe(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, sensorType);
+ }
+
+ @Override
+ public void startTracking(@NonNull @SampleType.SampleName final String sensorType,
+ @NonNull final Pair samplingRate,
+ @NonNull final SensorListener listener) {
+ final Context context = getContext();
+ if (isInvalidContext(context, listener, true)) return;
+ if (isSensorTypeUnavailable(sensorType, listener)) return;
+ if (!isAvailable()) {
+ listener.onSubscribe(INPUT_KIT_NOT_CONNECTED);
+ return;
+ }
+
+ assert context != null;
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ mSensorTracking.registerListener(sensorType, new SensorListener() {
+ @Override
+ public void onSubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.save(
+ context,
+ Constant.TRACKED_HEALTH_SENSORS,
+ Pair.create(sensorType, true)
+ );
+ }
+ listener.onSubscribe(info);
+ }
+
+ @Override
+ public void onReceive(@NonNull SensorDataPoint data) {
+ listener.onReceive(data);
+ }
+
+ @Override
+ public void onUnsubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.save(
+ context,
+ Constant.TRACKED_HEALTH_SENSORS,
+ Pair.create(sensorType, false)
+ );
+ }
+ listener.onUnsubscribe(info);
+ }
+ });
+ mSensorTracking.startTracking(sensorType, samplingRate);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ listener.onSubscribe(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, sensorType);
+ }
+
+ @Override
+ public void stopTracking(@NonNull final String sensorType,
+ @NonNull final SensorListener listener) {
+ final Context context = getContext();
+ if (isInvalidContext(context, listener, false)) return;
+ if (isSensorTypeUnavailable(sensorType, listener)) return;
+ if (!isAvailable()) {
+ listener.onUnsubscribe(INPUT_KIT_NOT_CONNECTED);
+ return;
+ }
+
+ assert context != null;
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ mSensorTracking.registerListener(sensorType, new SensorListener() {
+ @Override
+ public void onSubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.save(
+ context,
+ Constant.TRACKED_HEALTH_SENSORS,
+ Pair.create(sensorType, true)
+ );
+ }
+ listener.onSubscribe(info);
+ }
+
+ @Override
+ public void onReceive(@NonNull SensorDataPoint data) {
+ listener.onReceive(data);
+ }
+
+ @Override
+ public void onUnsubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.save(
+ context,
+ Constant.TRACKED_HEALTH_SENSORS,
+ Pair.create(sensorType, false)
+ );
+ }
+ listener.onUnsubscribe(info);
+ }
+ });
+ mSensorTracking.stopTracking(sensorType);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ listener.onUnsubscribe(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, sensorType);
+ }
+
+ @Override
+ public void stopTrackingAll(@NonNull final SensorListener listener) {
+ final Context context = getContext();
+ if (isInvalidContext(context, listener, false)) return;
+ if (!isAvailable()) {
+ listener.onUnsubscribe(INPUT_KIT_NOT_CONNECTED);
+ return;
+ }
+
+ assert context != null;
+ callWithValidToken(new AccessTokenListener() {
+ @Override
+ public void onSuccess() {
+ mSensorTracking.stopTrackingAll(new SensorListener() {
+ @Override
+ public void onSubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.saveAll(
+ context,
+ Constant.TRACKED_HEALTH_SENSORS,
+ true
+ );
+ }
+ listener.onSubscribe(info);
+ }
+
+ @Override
+ public void onReceive(@NonNull SensorDataPoint data) {
+ listener.onReceive(data);
+ }
+
+ @Override
+ public void onUnsubscribe(@NonNull IKResultInfo info) {
+ if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) {
+ HealthTrackerState.saveAll(
+ context,
+ Constant.TRACKED_HEALTH_SENSORS,
+ false
+ );
+ }
+ listener.onSubscribe(info);
+ }
+ });
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ listener.onUnsubscribe(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ }, SampleType.STEP_COUNT, SampleType.DISTANCE_WALKING_RUNNING);
+ }
+
+ /**
+ * Helper function to check whether sensor type is available or not.
+ * @param sensorType Sensor type
+ * @param listener {@link SensorListener}
+ * @return True if available, False otherwise.
+ */
+ private boolean isSensorTypeUnavailable(@NonNull String sensorType, @NonNull SensorListener listener) {
+ if (!SampleType.checkFitSampleType(sensorType).equals(SampleType.UNAVAILABLE)) return false;
+ listener.onSubscribe(
+ new IKResultInfo(
+ IKStatus.Code.INVALID_REQUEST,
+ sensorType + " : SENSOR_TYPE_IS_NOT_AVAILABLE!"
+ )
+ );
+ return true;
+ }
+
+ /**
+ * Check either context is still valid or not.
+ * @param context Current application context.
+ * @param callback Result callback
+ * @return True if context is valid, False otherwise.
+ */
+ private boolean isInvalidContext(@Nullable Context context,
+ @NonNull Result callback) {
+ if (context != null) return false;
+
+ onUnreachableContext(callback);
+ return true;
+ }
+
+ /**
+ *
+ * Check either context is still valid or not.
+ * @param context Current application context.
+ * @param listener Sensor listener.
+ * @param isSubscribeAction Set to true if it's coming from subscriptions, False otherwise.
+ * @return True if context is valid, False otherwise.
+ */
+ private boolean isInvalidContext(@Nullable Context context,
+ @NonNull SensorListener listener,
+ boolean isSubscribeAction) {
+ if (context != null) return false;
+
+ if (isSubscribeAction) listener.onSubscribe(UNREACHABLE_CONTEXT);
+ else listener.onUnsubscribe(UNREACHABLE_CONTEXT);
+ onUnreachableContext();
+ return true;
+ }
+
+ private void callWithValidToken(@NonNull final AccessTokenListener listener, final String... permissionTypes) {
+ final Context context = getContext();
+
+ if (context == null) {
+ listener.onFailure(new Exception(IKStatus.INPUT_KIT_UNREACHABLE_CONTEXT));
+ return;
+ }
+
+ GoogleSignInAccount account = GoogleSignIn.getLastSignedInAccount(context);
+ if (account != null && account.isExpired()) {
+ startSilentLoggedIn(context, listener, permissionTypes);
+ return;
+ }
+
+ if (account == null) {
+ // If we don't have last signed in account then client should call authorize request first.
+ listener.onFailure(new Exception(IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS));
+ return;
+ }
+
+ Log.d(TAG, "=========== @@@TOKEN IS VALID@@@ ===========");
+ listener.onSuccess();
+ }
+
+ /**
+ * Perform Sign-in Interactively.
+ * This is required if user never logged-in with Google Account before.
+ * https://developers.google.com/games/services/android/signin#performing_interactive_sign-in
+ *
+ * @param permissionTypes Sample data type of permission that we need to ask for.
+ */
+ private void startSignedInAndAskForPermission(String... permissionTypes) {
+ // Performing UI logged in only if possible.
+ if (getHostActivity() != null && getContext() != null) {
+ GoogleSignInClient signInClient = GoogleSignIn.getClient(getContext(), getOptions(permissionTypes));
+ Intent intent = signInClient.getSignInIntent();
+ getHostActivity().startActivityForResult(intent, GF_PERMISSION_REQUEST_CODE);
+ }
+ }
+
+ /**
+ * Start silent logged in to Access Fit API in case short-lived access token invalid.
+ * @param context Current application context
+ * @param listener Access token listener
+ * @param permissionTypes Sample data type of permission that we need to ask for.
+ */
+ private void startSilentLoggedIn(@NonNull Context context,
+ @NonNull final AccessTokenListener listener,
+ final String... permissionTypes) {
+
+ GoogleSignInAccount account = GoogleSignIn.getLastSignedInAccount(context);
+ if (account != null) {
+ Log.d(TAG, "=========== !!!FITNESS ACCESS TOKEN IS INVALID!!! ===========");
+ Log.d(TAG, "=========== !!!STARTING TO PERFORM SILENT LOGIN!!! ===========");
+ GoogleSignIn.getClient(context, getOptions(permissionTypes))
+ .silentSignIn()
+ .addOnCompleteListener(new OnCompleteListener() {
+ @Override
+ public void onComplete(@NonNull Task task) {
+ if (task.isSuccessful()) {
+ Log.d(TAG, "=========== !!!RENEWAL ACCESS TOKEN SUCCESS!!! ===========");
+ listener.onSuccess();
+ } else {
+ Log.d(TAG, "=========== !!!RENEWAL ACCESS TOKEN FAILED!!! ===========");
+ Exception err = new Exception("Unable to perform silent logged in!");
+ if (task.getException() != null) {
+ err = task.getException();
+ }
+ err.printStackTrace();
+ listener.onFailure(err);
+
+ // FIXME:
+ // If we are unable to perform silent logged in, then we have no choice
+ // unless we perform interactive logged in.
+ // BUT it also has a drawback, due to popup might appear a couple times
+ // each time silent logged in fail.
+ // startSignedInAndAskForPermission(permissionTypes);
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Get default google sign in options that being used for Google Fit.
+ * @param permissionTypes Sample data type of permission that we need to ask for.
+ * @return {@link GoogleSignInOptions}
+ */
+ private GoogleSignInOptions getOptions(String... permissionTypes) {
+ return new GoogleSignInOptions.Builder()
+ .requestId()
+ .requestEmail()
+ .addExtension(FitPermissionSet.getInstance().getPermissionsSet(permissionTypes))
+ .build();
+ }
+
+ interface AccessTokenListener {
+ void onSuccess();
+ void onFailure(Exception e);
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/DataNormalizer.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/DataNormalizer.java
new file mode 100644
index 0000000..66a849b
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/DataNormalizer.java
@@ -0,0 +1,399 @@
+package nl.sense.rninputkit.inputkit.googlefit.history;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Pair;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nl.sense.rninputkit.inputkit.entity.DateContent;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+import nl.sense.rninputkit.inputkit.entity.TimeInterval;
+import nl.sense.rninputkit.inputkit.helper.CollectionUtils;
+
+import static nl.sense.rninputkit.inputkit.helper.InputKitTimeUtils.getMinuteDiff;
+import static nl.sense.rninputkit.inputkit.helper.InputKitTimeUtils.isOverlappingTimeWindow;
+import static nl.sense.rninputkit.inputkit.helper.InputKitTimeUtils.isWithinTimeWindow;
+import static nl.sense.rninputkit.inputkit.helper.InputKitTimeUtils.populateTimeWindows;
+
+public abstract class DataNormalizer {
+
+ /**
+ * Setup input kit values according to source values.
+ * Next item is required to distribute source value into current and the next items
+ * in case it overlap both current and next time periods.
+ *
+ * @param currentItem Current item input kit value
+ * @param nextItem Next item input kit value
+ * @param sourceValues Source values
+ */
+ protected abstract void setValueItems(
+ @NonNull IKValue currentItem,
+ @Nullable IKValue nextItem,
+ @NonNull List> sourceValues);
+
+ /**
+ * Normalize input kit values time window.
+ *
+ * @param values Input kit values
+ * @param interval {@link TimeInterval}
+ * @return Step history within proper time windows.
+ */
+ @NonNull
+ public List> normalize(long startTime,
+ long endTime,
+ @NonNull List> values,
+ @NonNull TimeInterval interval) {
+ // populate proper time windows
+ List> timeWindows = populateTimeWindows(
+ startTime,
+ endTime,
+ interval
+ );
+
+ // make sure to sort input kit values ascending
+ CollectionUtils.sort(true, values);
+ List> ikValues = populateIKValues(timeWindows);
+
+ // setup input kit values
+ setupIKValues(ikValues, values);
+ return ikValues;
+ }
+
+ /**
+ * Populate proper time period for input kit values.
+ *
+ * @param timeWindows Time windows
+ * @return Input kit values with proper time period
+ */
+ @NonNull
+ private List> populateIKValues(@NonNull List> timeWindows) {
+ List> results = new ArrayList<>();
+ for (Pair normalizedTimeWindow : timeWindows) {
+ results.add(new IKValue(
+ new DateContent(normalizedTimeWindow.first),
+ new DateContent(normalizedTimeWindow.second))
+ );
+ }
+ return results;
+ }
+
+ /**
+ * Setup input kit values according to source values
+ * @param ikValues Input kit values within proper time period
+ * @param sourceValues Source values
+ */
+ private void setupIKValues(@NonNull List> ikValues,
+ @NonNull List> sourceValues) {
+ for (int i = 0; i < ikValues.size(); i++) {
+ IKValue currentItem = ikValues.get(i);
+ IKValue nextItem = i == ikValues.size() - 1
+ ? null : ikValues.get(i + 1);
+ setValueItems(currentItem, nextItem, sourceValues);
+ }
+ }
+
+ /**
+ * Get pair of time period of current item and the next item.
+ *
+ * @param currentItem Current item input kit value
+ * @param nextItem Next item input kit value
+ * @return Pair of time period of current item and the next item
+ */
+ private TimePeriod getPairTimePeriod(
+ @NonNull IKValue> currentItem,
+ @Nullable IKValue> nextItem) {
+ Pair currentTimePeriod = Pair.create(currentItem.getStartDate().getEpoch(),
+ currentItem.getEndDate().getEpoch());
+ Pair nextTimePeriod = nextItem == null
+ ? null
+ : Pair.create(nextItem.getStartDate().getEpoch(), nextItem.getEndDate().getEpoch());
+ return new TimePeriod(currentTimePeriod, nextTimePeriod);
+ }
+
+ /**
+ * Set current and next item value according to source values in float data type.
+ *
+ * @param currentItem Current item input kit value
+ * @param nextItem Next item input kit value
+ * @param sourceValues Source values
+ */
+ protected void setAsFloat(
+ @NonNull IKValue currentItem,
+ @Nullable IKValue nextItem,
+ @NonNull List> sourceValues) {
+ TimePeriod timePeriod = getPairTimePeriod(currentItem, nextItem);
+
+ // Get pair of overlap values.
+ // First item will be added to current item, second value will be added to the next value.
+ ValueItems valueItems = getValuePair(timePeriod.currentPeriod,
+ timePeriod.nexTimePeriod, sourceValues);
+
+ // Setup current value.
+ Float value = currentItem.getValue();
+ float incomingValue = valueItems.current.floatValue();
+ currentItem.setValue(value == null
+ ? incomingValue
+ : (value + incomingValue));
+
+ if (nextItem != null) {
+ currentItem.setValue(currentItem.getValue() + valueItems.floatOffset);
+ nextItem.setValue(valueItems.next.floatValue());
+ }
+ }
+
+ /**
+ * Set current and next item value according to source values in integer data type.
+ *
+ * @param currentItem Current item input kit value
+ * @param nextItem Next item input kit value
+ * @param sourceValues Source values
+ */
+ protected void setAsInt(
+ @NonNull IKValue currentItem,
+ @Nullable IKValue nextItem,
+ @NonNull List> sourceValues) {
+ TimePeriod timePeriod = getPairTimePeriod(currentItem, nextItem);
+
+ // Get pair of overlap values.
+ // First item will be added to current item, second value will be added to the next value.
+ ValueItems valueItems = getValuePair(timePeriod.currentPeriod,
+ timePeriod.nexTimePeriod, sourceValues);
+
+ // Setup current value.
+ Integer value = currentItem.getValue();
+ int incomingVal = Math.round(valueItems.current.floatValue());
+ currentItem.setValue(value == null
+ ? incomingVal
+ : (value + incomingVal));
+
+ if (nextItem != null) {
+ currentItem.setValue(currentItem.getValue() + valueItems.intOffset);
+ nextItem.setValue(Math.round(valueItems.next.floatValue()));
+ }
+ }
+
+ /**
+ * Get value for current and next value item according to source values.
+ *
+ * @param currentTimePeriod Current time period input kit value
+ * @param nextTimePeriod Next time period of input kit value
+ * @param sourceValues Source values
+ * @return Pair of total value source.
+ * - First value is a total of source values if it's completely inside time period
+ * of current item.
+ * - Second value is distributed source values if it's overlap current time period or next item
+ * time period as well.
+ */
+ private ValueItems getValuePair(
+ @NonNull Pair currentTimePeriod,
+ @Nullable Pair nextTimePeriod,
+ @NonNull List> sourceValues) {
+ Number totalValue = 0, nextValue = 0, actualValue = 0;
+ for (IKValue value : sourceValues) {
+ Pair valueTimePeriod = Pair.create(value.getStartDate().getEpoch(),
+ value.getEndDate().getEpoch());
+
+ // Stop counting if value time period exceed end time of the next item time period.
+ if (nextTimePeriod != null && valueTimePeriod.second > nextTimePeriod.second) {
+ break;
+ }
+
+ // Sum up current total value with source value when it still completely within time period.
+ if (isWithinTimeWindow(valueTimePeriod.first, valueTimePeriod.second, currentTimePeriod)) {
+ totalValue = sumValues(totalValue, value.getValue());
+ actualValue = sumValues(actualValue, value.getValue());
+ continue;
+ }
+
+ // Distribute value source to current and the next item when it's overlap.
+ if (isOverlappingTimeWindow(valueTimePeriod.first, valueTimePeriod.second, currentTimePeriod)
+ && !value.isFlaggedOverlap()) {
+ Pair overlappingValuePair = getOverlappingValuePair(currentTimePeriod, value);
+ totalValue = sumValues(totalValue, overlappingValuePair.first);
+ actualValue = sumValues(actualValue, value.getValue());
+ nextValue = overlappingValuePair.second;
+ value.setFlagOverlap(true);
+ break;
+ }
+ }
+ return new ValueItems(totalValue, nextValue, actualValue);
+ }
+
+ /**
+ * Distribute value among current and the next item when source value item overlap those.
+ *
+ * @param currentTimePeriod Current time period of input kit value
+ * @param sourceValue Source value item
+ * @return Pair of overlapping value.
+ * First value is a total value for current item.
+ * Second value is an overlap value for the next item.
+ */
+ private Pair getOverlappingValuePair(
+ @NonNull Pair currentTimePeriod,
+ @NonNull IKValue sourceValue) {
+ // Get specific source value overlapping item information
+ Pair sourceTimePeriod = Pair.create(sourceValue.getStartDate().getEpoch(),
+ sourceValue.getEndDate().getEpoch());
+ boolean isStartWithinTimePeriod = isWithinTimeWindow(sourceTimePeriod.first, currentTimePeriod);
+ boolean isEndWithinTimePeriod = isWithinTimeWindow(sourceTimePeriod.second, currentTimePeriod);
+
+ // It means : Source value end time exceed an end time of current time period
+ // In this case, we distribute `right`-extra-value to the next input kit item
+ //
+ // eg.
+ // - current time period : 08.00 - 08.10
+ // - source time period : 08.00 - 08.11
+ if (isStartWithinTimePeriod && !isEndWithinTimePeriod
+ && sourceTimePeriod.second >= currentTimePeriod.second) {
+ return getValuePair(sourceValue, currentTimePeriod.second, sourceTimePeriod);
+ }
+
+ // It means : Source value `start-time` was below of `start-time` of the current time period
+ // In this case, we exclude `left`-extra-value and calculate average value within time period
+ // to the current input kit item
+ //
+ // eg.
+ // - current time period : 08.00 - 08.10
+ // - source time period : 07.58 - 08.08
+ if (!isStartWithinTimePeriod && sourceTimePeriod.first < currentTimePeriod.first
+ && isEndWithinTimePeriod) {
+ Pair valuePair = getValuePair(sourceValue,
+ currentTimePeriod.first, sourceTimePeriod);
+ return Pair.create(valuePair.second, 0f);
+ }
+
+ // It means : Time period was completely within source value `time-window`. In this case,
+ // we only calculate value for intersects `time-window` of source value and current time
+ // period. Then those calculated value will be distributed to the current input kit item.
+ //
+ // eg.
+ // - current time period : 08.00 - 08.10
+ // - source time period : 07.58 - 08.18
+ if (!isStartWithinTimePeriod && sourceTimePeriod.first < currentTimePeriod.first
+ && !isEndWithinTimePeriod && sourceTimePeriod.second >= currentTimePeriod.second) {
+ float srcAvgPerMinute = averageValuePerMinute(sourceValue);
+ long timePeriodMinDiff = getMinuteDiff(currentTimePeriod.second, currentTimePeriod.first);
+ return Pair.create(srcAvgPerMinute * timePeriodMinDiff, 0f);
+ }
+
+ return Pair.create(0f, 0f);
+ }
+
+ /**
+ * Get left-right step count value distribution per minute.
+ * @param sourceValue Source value
+ * @param anchorTime Anchor time
+ * @param sourceTimePeriod Source time period
+ * @return Pair of left and right overlap value.
+ */
+ private Pair getValuePair(
+ @NonNull IKValue sourceValue,
+ long anchorTime,
+ @NonNull Pair sourceTimePeriod) {
+ float srcAvgPerMinute = averageValuePerMinute(sourceValue);
+ long leftMinDiff = getMinuteDiff(sourceTimePeriod.first, anchorTime);
+ long rightMinDiff = getMinuteDiff(anchorTime, sourceTimePeriod.second);
+ if (leftMinDiff == 0 && rightMinDiff == 0) {
+ // In this case, source time period overlap anchor time within milliseconds.
+ // Then distribute average value into current item of input kit value.
+ return Pair.create(srcAvgPerMinute, 0f);
+ }
+ return Pair.create(srcAvgPerMinute * leftMinDiff, srcAvgPerMinute * rightMinDiff);
+ }
+
+ /**
+ * Sum values in a number data type
+ *
+ * @param previous Previous value
+ * @param current Current value
+ * @return Sum of previous and current value if data type recognised.
+ * Otherwise, previous value will be returned.
+ */
+ private Number sumValues(X previous, Number current) {
+ if (current instanceof Long) {
+ return previous.longValue() + current.longValue();
+ }
+ if (current instanceof Float) {
+ return previous.floatValue() + current.floatValue();
+ }
+ if (current instanceof Integer) {
+ return previous.intValue() + current.intValue();
+ }
+ if (current instanceof Double) {
+ return previous.doubleValue() + current.doubleValue();
+ }
+ if (current instanceof Short) {
+ return previous.shortValue() + current.shortValue();
+ }
+ if (current instanceof Byte) {
+ return previous.byteValue() + current.byteValue();
+ }
+ return previous;
+ }
+
+ /**
+ * Average value per minutes
+ *
+ * @param sourceValue Source value
+ * @return Average value per minute if data type recognised.
+ * Otherwise, default value (1L) will be returned.
+ */
+ private float averageValuePerMinute(IKValue sourceValue) {
+ float minDiff = getMinuteDiff(sourceValue.getEndDate().getEpoch(),
+ sourceValue.getStartDate().getEpoch());
+ minDiff = minDiff == 0f ? 1f : minDiff;
+ Number value = sourceValue.getValue();
+ if (value instanceof Long) {
+ return value.longValue() / minDiff;
+ }
+ if (value instanceof Float) {
+ return value.floatValue() / minDiff;
+ }
+ if (value instanceof Integer) {
+ return value.intValue() / minDiff;
+ }
+ if (value instanceof Double) {
+ return (float) value.doubleValue() / minDiff;
+ }
+ if (value instanceof Short) {
+ return value.shortValue() / minDiff;
+ }
+ if (value instanceof Byte) {
+ return value.byteValue() / minDiff;
+ }
+ return 1f;
+ }
+
+ class ValueItems {
+ private Number current;
+ private Number next;
+ private Number actual;
+ private int intOffset = 0;
+ private float floatOffset = 0.f;
+
+ ValueItems(Number current, Number next, Number actual) {
+ this.current = current;
+ this.next = next;
+ this.actual = actual;
+ calculateOffset();
+ }
+
+ private void calculateOffset() {
+ this.intOffset = actual.intValue()
+ - (Math.round(current.floatValue()) + Math.round(next.floatValue()));
+ this.floatOffset = actual.floatValue() - (current.floatValue() + next.floatValue());
+ }
+ }
+
+ class TimePeriod {
+ private Pair currentPeriod;
+ private Pair nexTimePeriod;
+
+ TimePeriod(Pair currentPeriod, Pair nexTimePeriod) {
+ this.currentPeriod = currentPeriod;
+ this.nexTimePeriod = nexTimePeriod;
+ }
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/DistanceHistoryTask.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/DistanceHistoryTask.java
new file mode 100644
index 0000000..c817fcb
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/DistanceHistoryTask.java
@@ -0,0 +1,145 @@
+package nl.sense.rninputkit.inputkit.googlefit.history;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Pair;
+
+import com.google.android.gms.fitness.data.DataPoint;
+import com.google.android.gms.fitness.data.DataType;
+import com.google.android.gms.fitness.result.DataReadResponse;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import nl.sense.rninputkit.inputkit.Options;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+
+class DistanceHistoryTask extends HistoryTaskFactory {
+ private DataNormalizer normalizer = new DataNormalizer() {
+ @NonNull
+ @Override
+ protected void setValueItems(@NonNull IKValue currentItem,
+ @Nullable IKValue nextItem,
+ @NonNull List> sourceValues) {
+ this.setAsFloat(currentItem, nextItem, sourceValues);
+ }
+ };
+
+ private HistoryExtractor extractor = new HistoryExtractor() {
+ @Override
+ protected Float getDataPointValue(@Nullable DataPoint dataPoint) {
+ return this.asFloat(dataPoint);
+ }
+ };
+
+ private DistanceHistoryTask(IFitReader fitDataReader,
+ List> safeRequests,
+ Options options,
+ DataType dataTypeRequest,
+ Pair aggregateType,
+ OnCompleteListener onCompleteListener,
+ OnFailureListener onFailureListener) {
+ super(fitDataReader,
+ safeRequests,
+ options,
+ dataTypeRequest,
+ aggregateType,
+ onCompleteListener,
+ onFailureListener
+ );
+ }
+
+ @Override
+ protected List> getValues(List responses) {
+ if (responses == null) return Collections.emptyList();
+
+ List> fitValues = new ArrayList<>();
+ for (DataReadResponse response : responses) {
+ if (!response.getStatus().isSuccess()) continue;
+
+ // extract value history
+ List> values = extractor.extractHistory(response, options.isUseDataAggregation());
+
+ // check data source availability
+ if (values.isEmpty()) continue;
+
+ fitValues.addAll(values);
+ }
+
+ // normalise time window
+ return normalizer.normalize(options.getStartTime(),
+ options.getEndTime(), fitValues, options.getTimeInterval());
+ }
+
+ static class Builder {
+ private IFitReader fitDataReader;
+ private List> safeRequests;
+ private Options options;
+ private DataType dataTypeRequest;
+ private Pair aggregateType;
+ private OnCompleteListener onCompleteListener;
+ private OnFailureListener onFailureListener;
+
+ Builder withFitDataReader(IFitReader fitDataReader) {
+ this.fitDataReader = fitDataReader;
+ return this;
+ }
+
+ Builder addSafeRequests(List> safeRequests) {
+ this.safeRequests = safeRequests;
+ return this;
+ }
+
+ Builder addOptions(Options options) {
+ this.options = options;
+ return this;
+ }
+
+ Builder addDataType(DataType dataTypeRequest) {
+ this.dataTypeRequest = dataTypeRequest;
+ return this;
+ }
+
+ Builder addAggregateTypes(Pair aggregateType) {
+ this.aggregateType = aggregateType;
+ return this;
+ }
+
+ Builder addOnCompleteListener(OnCompleteListener onCompleteListener) {
+ this.onCompleteListener = onCompleteListener;
+ return this;
+ }
+
+ Builder addOnFailureListener(OnFailureListener onFailureListener) {
+ this.onFailureListener = onFailureListener;
+ return this;
+ }
+
+ private void validate() {
+ if (fitDataReader == null)
+ throw new IllegalStateException("Fit history must be provided.");
+ if (safeRequests == null)
+ throw new IllegalStateException("Time requests must be provided.");
+ if (dataTypeRequest == null)
+ throw new IllegalStateException("Data type request must be provided.");
+ if (aggregateType == null)
+ throw new IllegalStateException("Aggregate type must be provided.");
+ if (options == null)
+ throw new IllegalStateException("Options history must be provided.");
+ }
+
+ DistanceHistoryTask build() {
+ validate();
+ return new DistanceHistoryTask(
+ fitDataReader,
+ safeRequests,
+ options,
+ dataTypeRequest,
+ aggregateType,
+ onCompleteListener,
+ onFailureListener
+ );
+ }
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/FitHistory.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/FitHistory.java
new file mode 100644
index 0000000..c5d083a
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/FitHistory.java
@@ -0,0 +1,285 @@
+package nl.sense.rninputkit.inputkit.googlefit.history;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Pair;
+
+import com.google.android.gms.auth.api.signin.GoogleSignIn;
+import com.google.android.gms.fitness.Fitness;
+import com.google.android.gms.fitness.data.DataPoint;
+import com.google.android.gms.fitness.data.DataSet;
+import com.google.android.gms.fitness.data.DataSource;
+import com.google.android.gms.fitness.data.DataType;
+import com.google.android.gms.fitness.request.DataReadRequest;
+import com.google.android.gms.fitness.result.DataReadResponse;
+import com.google.android.gms.tasks.OnFailureListener;
+import com.google.android.gms.tasks.OnSuccessListener;
+import com.google.android.gms.tasks.Task;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nl.sense.rninputkit.inputkit.InputKit.Result;
+import nl.sense.rninputkit.inputkit.Options;
+import nl.sense.rninputkit.inputkit.constant.IKStatus;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+import nl.sense.rninputkit.inputkit.entity.StepContent;
+import nl.sense.rninputkit.inputkit.entity.TimeInterval;
+import nl.sense.rninputkit.inputkit.status.IKResultInfo;
+
+/**
+ * Created by panjiyudasetya on 6/15/17.
+ */
+
+@SuppressWarnings("SpellCheckingInspection")
+public class FitHistory implements IFitReader {
+ private Context mContext;
+ private SafeRequestHandler mSafeRequestHandler;
+
+ public FitHistory(@NonNull Context context) {
+ this.mContext = context;
+ this.mSafeRequestHandler = new SafeRequestHandler();
+ }
+
+ /**
+ * Get total distance of walk within specific options.
+ *
+ * @param options {@link Options}
+ * @param callback {@link Result} containing number of total distance
+ */
+ public void getDistance(@NonNull final Options options,
+ @NonNull final Result callback) {
+ // Invoke the History API to fetch the data with the query and await the result of
+ // the read request.
+ List> safeRequests = mSafeRequestHandler.getSafeRequest(options.getStartTime(),
+ options.getEndTime(), options.getTimeInterval());
+ new DistanceHistoryTask.Builder()
+ .withFitDataReader(this)
+ .addSafeRequests(safeRequests)
+ .addOptions(options)
+ .addDataType(DataType.TYPE_DISTANCE_DELTA)
+ .addAggregateTypes(Pair.create(DataType.TYPE_DISTANCE_DELTA, DataType.AGGREGATE_DISTANCE_DELTA))
+ .addOnCompleteListener(new HistoryTaskFactory.OnCompleteListener() {
+ @Override
+ public void onComplete(List> result) {
+ callback.onNewData(IKValue.getTotalFloats(result));
+ }
+ })
+ .addOnFailureListener(new HistoryTaskFactory.OnFailureListener() {
+ @Override
+ public void onFailure(List exceptions) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ exceptions.get(0).getMessage()));
+ }
+ })
+ .build()
+ .start();
+ }
+
+ /**
+ * Get sample distance of walk within specific options.
+ *
+ * @param options {@link Options}
+ * @param callback {@link Result} containing number of total distance
+ */
+ public void getDistanceSamples(@NonNull final Options options,
+ @NonNull final Result>> callback) {
+ // Invoke the History API to fetch the data with the query and await the result of
+ // the read request.
+ List> safeRequests = mSafeRequestHandler.getSafeRequest(options.getStartTime(),
+ options.getEndTime(), options.getTimeInterval());
+ new DistanceHistoryTask.Builder()
+ .withFitDataReader(this)
+ .addSafeRequests(safeRequests)
+ .addOptions(options)
+ .addDataType(DataType.TYPE_DISTANCE_DELTA)
+ .addAggregateTypes(Pair.create(DataType.TYPE_DISTANCE_DELTA, DataType.AGGREGATE_DISTANCE_DELTA))
+ .addOnCompleteListener(new HistoryTaskFactory.OnCompleteListener() {
+ @Override
+ public void onComplete(List> result) {
+ callback.onNewData(applyLimitation(options.getLimitation(), result));
+ }
+ })
+ .addOnFailureListener(new HistoryTaskFactory.OnFailureListener() {
+ @Override
+ public void onFailure(List exceptions) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ exceptions.get(0).getMessage()));
+ }
+ })
+ .build()
+ .start();
+ }
+
+ /**
+ * Get daily total step count.
+ *
+ * @param callback {@link Result} containing number of total steps count
+ */
+ public void getStepCount(@NonNull final Result callback) {
+ Fitness.getHistoryClient(mContext, GoogleSignIn.getLastSignedInAccount(mContext))
+ .readDailyTotal(DataType.TYPE_STEP_COUNT_DELTA)
+ .addOnSuccessListener(new OnSuccessListener() {
+ @Override
+ public void onSuccess(DataSet dataSet) {
+ List> contents = new HistoryExtractor() {
+ @Override
+ protected Integer getDataPointValue(@Nullable DataPoint dataPoint) {
+ return this.asInt(dataPoint);
+ }
+ }.historyFromDataSet(dataSet);
+ callback.onNewData(IKValue.getTotalIntegers(contents));
+ }
+ })
+ .addOnFailureListener(new OnFailureListener() {
+ @Override
+ public void onFailure(@NonNull Exception e) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ e.getMessage()));
+ }
+ });
+ }
+
+ /**
+ * Get total steps count of specific range
+ *
+ * @param options Steps count options
+ * @param callback {@link Result } containing number of total steps count
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void getStepCount(@NonNull final Options options,
+ @NonNull final Result callback) {
+ // Invoke the History API to fetch the data with the query and await the result of
+ // the read request.
+ List> safeRequests = mSafeRequestHandler.getSafeRequest(options.getStartTime(),
+ options.getEndTime(), options.getTimeInterval());
+ new StepCountHistoryTask.Builder()
+ .withFitDataReader(this)
+ .addSafeRequests(safeRequests)
+ .addOptions(options)
+ .addDataType(DataType.TYPE_STEP_COUNT_DELTA)
+ .addAggregateSourceType(Pair.create(getFitStepCountDataSource(), DataType.AGGREGATE_STEP_COUNT_DELTA))
+ .addOnCompleteListener(new HistoryTaskFactory.OnCompleteListener() {
+ @Override
+ public void onComplete(List> result) {
+ callback.onNewData(IKValue.getTotalIntegers(result));
+ }
+ })
+ .addOnFailureListener(new HistoryTaskFactory.OnFailureListener() {
+ @Override
+ public void onFailure(List exceptions) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ exceptions.get(0).getMessage()));
+ }
+ })
+ .build()
+ .start();
+ }
+
+ /**
+ * Get distribution step count history by specific time period.
+ * This function should be called within asynchronous process because of
+ * reading historical data through {@link Fitness#HistoryApi} will be executed on main
+ * thread by default.
+ *
+ * @param options Steps count options
+ * @param callback {@link Result} containing a set of step content
+ */
+ @SuppressWarnings("unused")//This is a public API
+ public void getStepCountDistribution(@NonNull final Options options,
+ @NonNull final Result callback) {
+ // Invoke the History API to fetch the data with the query and await the result of
+ // the read request.
+ List> safeRequests = mSafeRequestHandler.getSafeRequest(options.getStartTime(),
+ options.getEndTime(), options.getTimeInterval());
+ new StepCountHistoryTask.Builder()
+ .withFitDataReader(this)
+ .addSafeRequests(safeRequests)
+ .addOptions(options)
+ .addDataType(DataType.TYPE_STEP_COUNT_DELTA)
+ .addAggregateSourceType(Pair.create(getFitStepCountDataSource(), DataType.AGGREGATE_STEP_COUNT_DELTA))
+ .addOnCompleteListener(new HistoryTaskFactory.OnCompleteListener() {
+ @Override
+ public void onComplete(List> result) {
+ StepContent content = StepCountHistoryTask.toStepContent(
+ applyLimitation(options.getLimitation(), result),
+ options.getStartTime(), options.getEndTime());
+ callback.onNewData(content);
+ }
+ })
+ .addOnFailureListener(new HistoryTaskFactory.OnFailureListener() {
+ @Override
+ public void onFailure(List exceptions) {
+ callback.onError(new IKResultInfo(IKStatus.Code.INVALID_REQUEST,
+ exceptions.get(0).getMessage()));
+ }
+ })
+ .build()
+ .start();
+ }
+
+ /**
+ * To make sure that returned step count data exactly the same with GoogleFit App
+ * we need to define Google Fit data source
+ * @return Google Fit datasource
+ */
+ private DataSource getFitStepCountDataSource() {
+ return new DataSource.Builder()
+ .setDataType(DataType.TYPE_STEP_COUNT_DELTA)
+ .setType(DataSource.TYPE_DERIVED)
+ .setStreamName("estimated_steps")
+ .setAppPackageName("com.google.android.gms")
+ .build();
+ }
+
+ @Override
+ public synchronized Task readHistory(long startTime,
+ long endTime,
+ boolean useDataAggregation,
+ @NonNull TimeInterval timeIntervalAggregator,
+ DataType fitDataType,
+ Pair, DataType> typeAggregator) {
+ DataReadRequest.Builder requestBuilder = new DataReadRequest.Builder();
+ if (useDataAggregation) {
+ // The data request can specify multiple data types to return, effectively
+ // combining multiple data queries into one call.
+ // In this example, it's very unlikely that the request is for several hundred
+ // data points each consisting of cumulative distance in meters and a timestamp.
+ // The more likely scenario is wanting to see how many distance were achieved
+ // per day, for several days.
+ if (DataSource.class.isInstance(typeAggregator.first)) {
+ requestBuilder.aggregate((DataSource) typeAggregator.first, typeAggregator.second);
+ } else if (DataType.class.isInstance(typeAggregator.first)) {
+ requestBuilder.aggregate((DataType) typeAggregator.first, typeAggregator.second);
+ } else {
+ throw new IllegalStateException("Unsupported aggregate type");
+ }
+ // Analogous to a "Group By" in SQL, defines how data should be aggregated.
+ // bucketByTime allows for a time span, whereas bucketBySession would allow
+ // bucketing by "sessions", which would need to be defined in code.
+ requestBuilder.bucketByTime(timeIntervalAggregator.getValue(), timeIntervalAggregator.getTimeUnit());
+ } else requestBuilder.read(fitDataType);
+
+ DataReadRequest request = requestBuilder
+ .setTimeRange(startTime, endTime, TimeUnit.MILLISECONDS)
+ .enableServerQueries()
+ .build();
+
+ return Fitness.getHistoryClient(mContext, GoogleSignIn.getLastSignedInAccount(mContext))
+ .readData(request);
+ }
+
+ /**
+ * Helper function to apply limitation from Client
+ * @param limit Data limitation
+ * @param data Current data result
+ * @param Data type
+ * @return Limited data set
+ */
+ private List applyLimitation(Integer limit, List data) {
+ if (limit == null || limit <= 0 || limit > data.size()) return data;
+ data.subList(limit, data.size()).clear();
+ return data;
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/HistoryExtractor.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/HistoryExtractor.java
new file mode 100644
index 0000000..5f2cf26
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/HistoryExtractor.java
@@ -0,0 +1,167 @@
+package nl.sense.rninputkit.inputkit.googlefit.history;
+
+import androidx.annotation.Nullable;
+import android.util.Log;
+
+import com.google.android.gms.fitness.data.Bucket;
+import com.google.android.gms.fitness.data.DataPoint;
+import com.google.android.gms.fitness.data.DataSet;
+import com.google.android.gms.fitness.data.Field;
+import com.google.android.gms.fitness.data.Value;
+import com.google.android.gms.fitness.result.DataReadResponse;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nl.sense.rninputkit.inputkit.entity.DateContent;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+
+/**
+ * Abstraction historical data extractor from Google Fitness API
+ * @param Expected output type
+ *
+ * Created by panjiyudasetya on 7/5/17.
+ */
+
+public abstract class HistoryExtractor {
+ private static final String TAG = "HistoryExtractor";
+
+ /**
+ * Helper function to extract historical data based on {@link DataReadResponse} and aggregation
+ * key
+ * @param dataReadResponse {@link DataReadResponse} history
+ * @param useDataAggregation Set true to aggregate existing data by a bucket of time periods
+ * @return {@link List>} Input kit values
+ */
+ public List> extractHistory(DataReadResponse dataReadResponse, boolean useDataAggregation) {
+ if (useDataAggregation) {
+ return historyFromBucket(dataReadResponse.getBuckets());
+ } else {
+ return historyFromDataSet(dataReadResponse.getDataSets());
+ }
+ }
+
+ /**
+ * Helper function to extract data points history from {@link DataSet}
+ * @param dataSet {@link DataSet}
+ * @return {@link List} Input kit values
+ */
+ public List> historyFromDataSet(@Nullable DataSet dataSet) {
+ if (dataSet == null) return Collections.emptyList();
+ Log.i(TAG, "Data returned for Data type: " + dataSet.getDataType().getName());
+
+ List> contents = new ArrayList<>();
+
+ for (DataPoint dp : dataSet.getDataPoints()) {
+ contents.add(new IKValue<>(
+ getDataPointValue(dp),
+ new DateContent(dp.getStartTime(TimeUnit.MILLISECONDS)),
+ new DateContent(dp.getEndTime(TimeUnit.MILLISECONDS))
+ ));
+ }
+ return contents;
+ }
+
+ /**
+ * Helper function to extract historical from {@link Bucket}
+ * @param buckets {@link List}
+ * @return {@link List>} Input kit values
+ */
+ private List> historyFromBucket(@Nullable List buckets) {
+ if (buckets == null || buckets.isEmpty()) return Collections.emptyList();
+
+ List> contents = new ArrayList<>();
+ int startFormIndex = 0;
+ for (Bucket bucket : buckets) {
+ List dataSets = bucket.getDataSets();
+ contents.addAll(startFormIndex, historyFromDataSet(dataSets));
+ startFormIndex = contents.size();
+ }
+ return contents;
+ }
+
+ /**
+ * Helper function to extract data point history from {@link DataSet}
+ * @param dataSets {@link DataSet}
+ * @return {@link List>} Input kit values
+ */
+ private List> historyFromDataSet(@Nullable List dataSets) {
+ if (dataSets == null || dataSets.isEmpty()) return Collections.emptyList();
+
+ List> contents = new ArrayList<>();
+ int startFormIndex = 0;
+ for (DataSet dataSet : dataSets) {
+ contents.addAll(startFormIndex, historyFromDataSet(dataSet));
+ startFormIndex = contents.size();
+ }
+
+ return contents;
+ }
+
+ /**
+ * Get data point value.
+ * @param dataPoint Detected value in {@link DataPoint}
+ * @return T value with specific type
+ */
+ protected abstract T getDataPointValue(@Nullable DataPoint dataPoint);
+
+ /**
+ * Convert data point as float value
+ * @param dataPoint Detected {@link DataPoint} from Fit history
+ * @return Float of data point value, otherwise 0.f will be returned.
+ * @throws {@link IllegalStateException} when `value.getFormat()` not equals a float
+ * -> 1 means data point value in integer format
+ * -> 2 means data point value in float format
+ * -> 3 means data point value in string format
+ */
+ public float asFloat(@Nullable DataPoint dataPoint) {
+ Value value = getValue(dataPoint);
+ return value == null ? 0.f : value.asFloat();
+ }
+
+ /**
+ * Convert data point as integer value
+ * @param dataPoint Detected {@link DataPoint} from Fit history
+ * @return Integer of data point value, otherwise 0 will be returned.
+ * @throws {@link IllegalStateException} when `value.getFormat()` not equals integer
+ * -> 1 means data point value in integer format
+ * -> 2 means data point value in float format
+ * -> 3 means data point value in string format
+ */
+ public int asInt(@Nullable DataPoint dataPoint) {
+ Value value = getValue(dataPoint);
+ return value == null ? 0 : value.asInt();
+ }
+
+ /**
+ * Convert data point as string value
+ * @param dataPoint Detected {@link DataPoint} from Fit history
+ * @return String of data point value, otherwise 0 will be returned.
+ * @throws {@link IllegalStateException} when `value.getFormat()` not equals string
+ * -> 1 means data point value in integer format
+ * -> 2 means data point value in float format
+ * -> 3 means data point value in string format
+ */
+ public String asString(@Nullable DataPoint dataPoint) {
+ Value value = getValue(dataPoint);
+ return value == null ? "" : value.asString();
+ }
+
+ /**
+ * Get data point value.
+ * @param dataPoint Detected {@link DataPoint}
+ * @return {@link Value} of detected data point
+ */
+ @Nullable
+ private Value getValue(@Nullable DataPoint dataPoint) {
+ if (dataPoint == null || dataPoint.getDataType() == null) return null;
+
+ List fields = dataPoint.getDataType().getFields();
+ if (fields == null || fields.isEmpty()) return null;
+
+ // Usually this fields only contains one row, so we can directly return the value
+ return dataPoint.getValue(fields.get(0));
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/HistoryTaskFactory.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/HistoryTaskFactory.java
new file mode 100644
index 0000000..3dca16a
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/HistoryTaskFactory.java
@@ -0,0 +1,139 @@
+package nl.sense.rninputkit.inputkit.googlefit.history;
+
+import android.os.AsyncTask;
+import android.util.Pair;
+
+import com.google.android.gms.fitness.data.DataType;
+import com.google.android.gms.fitness.result.DataReadResponse;
+import com.google.android.gms.tasks.Tasks;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import nl.sense.rninputkit.inputkit.Options;
+import nl.sense.rninputkit.inputkit.constant.Interval;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+import nl.sense.rninputkit.inputkit.entity.TimeInterval;
+
+public abstract class HistoryTaskFactory extends AsyncTask>> {
+ public interface OnCompleteListener {
+ void onComplete(List> result);
+ }
+ public interface OnFailureListener {
+ void onFailure(List exceptions);
+ }
+
+ private IFitReader fitDataReader;
+ private List> safeRequests;
+ private DataType dataTypeRequest;
+ private Pair, DataType> aggregateType;
+ private OnCompleteListener onCompleteListener;
+ private OnFailureListener onFailureListener;
+ private HistoryResponseSet responseSet;
+ protected Options options;
+
+ protected HistoryTaskFactory(IFitReader fitDataReader,
+ List> safeRequests,
+ Options options,
+ DataType dataTypeRequest,
+ Pair, DataType> aggregateType,
+ OnCompleteListener onCompleteListener,
+ OnFailureListener onFailureListener) {
+ this.fitDataReader = fitDataReader;
+ this.safeRequests = safeRequests;
+ this.options = options;
+ this.dataTypeRequest = dataTypeRequest;
+ this.aggregateType = aggregateType;
+ this.onCompleteListener = onCompleteListener;
+ this.onFailureListener = onFailureListener;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ responseSet = new HistoryResponseSet();
+ }
+
+ @Override
+ protected List> doInBackground(Void... aVoid) {
+ for (Pair request : safeRequests) {
+ try {
+ TimeInterval intervalAggregator = options.getTimeInterval()
+ .getTimeUnit() == TimeUnit.DAYS
+ ? new TimeInterval(Interval.ONE_DAY)
+ : options.getTimeInterval();
+ Pair timeout = intervalAggregator
+ .getTimeUnit() == TimeUnit.DAYS
+ ? Pair.create(150, TimeUnit.SECONDS)
+ : Pair.create(1, TimeUnit.MINUTES);
+ DataReadResponse response = Tasks.await(
+ fitDataReader.readHistory(
+ request.first,
+ request.second,
+ options.isUseDataAggregation(),
+ intervalAggregator,
+ dataTypeRequest,
+ aggregateType
+ ), timeout.first, timeout.second);
+ responseSet.addResponse(response);
+ } catch (ExecutionException | InterruptedException | TimeoutException e) {
+ responseSet.addException(e);
+ }
+ }
+
+ return getValues(responseSet.responses());
+ }
+
+ @Override
+ protected void onPostExecute(List> results) {
+ if (!responseSet.responses().isEmpty() || responseSet.exceptions().isEmpty()) {
+ if (onCompleteListener != null) onCompleteListener.onComplete(results);
+ return;
+ }
+
+ if (onFailureListener != null) onFailureListener.onFailure(responseSet.exceptions());
+ }
+
+ /**
+ * Get mapped input kit values from data response
+ * @param responses Collection of {@link DataReadResponse} if distance sample
+ * @return List of distance sample
+ */
+ protected abstract List> getValues(List responses);
+
+ /**
+ * Execute task history within thread pool executor
+ */
+ public void start() {
+ executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ class HistoryResponseSet {
+ private List responses;
+ private List exceptions;
+
+ HistoryResponseSet() {
+ this.responses = new ArrayList<>();
+ this.exceptions = new ArrayList<>();
+ }
+
+ void addResponse(DataReadResponse response) {
+ responses.add(response);
+ }
+
+ void addException(Exception exception) {
+ exceptions.add(exception);
+ }
+
+ List responses() {
+ return responses;
+ }
+
+ List exceptions() {
+ return exceptions;
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/IFitReader.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/IFitReader.java
new file mode 100644
index 0000000..72638f7
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/IFitReader.java
@@ -0,0 +1,31 @@
+package nl.sense.rninputkit.inputkit.googlefit.history;
+
+import androidx.annotation.NonNull;
+import android.util.Pair;
+
+import com.google.android.gms.fitness.data.DataType;
+import com.google.android.gms.fitness.result.DataReadResponse;
+import com.google.android.gms.tasks.Task;
+
+import nl.sense.rninputkit.inputkit.entity.TimeInterval;
+
+public interface IFitReader {
+ /**
+ * Read historical data from Fitness API.
+ *
+ * @param startTime Start time cumulative distance
+ * @param endTime End time cumulative distance
+ * @param useDataAggregation Set true to aggregate existing data by a bucket of time periods
+ * @param timeIntervalAggregator Time Interval for data aggregation
+ * @param fitDataType Fitness data type
+ * @param typeAggregator Pair of aggregator data type.
+ * First value must be source of aggregate. eg.
+ * Second value must be aggregate value.
+ */
+ Task readHistory(long startTime,
+ long endTime,
+ boolean useDataAggregation,
+ @NonNull TimeInterval timeIntervalAggregator,
+ DataType fitDataType,
+ Pair, DataType> typeAggregator);
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/SafeRequestHandler.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/SafeRequestHandler.java
new file mode 100644
index 0000000..b9819ed
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/SafeRequestHandler.java
@@ -0,0 +1,137 @@
+package nl.sense.rninputkit.inputkit.googlefit.history;
+
+import androidx.annotation.NonNull;
+import android.util.Pair;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nl.sense.rninputkit.inputkit.entity.TimeInterval;
+
+public class SafeRequestHandler {
+ /**
+ * As for minutely request, we should use safe number for maximum datapoints within those
+ * period.
+ * eg.:
+ * - 24 hours * 1 minute datapoints = 1440 datapoint <- this one would be pretty rare. consider
+ * no one would keep walking or running
+ * for entire 24 hours without a rest
+ * - 24 hours * 10 minute datapoints = 144 datapoint
+ * - 24 hours * 30 minute datapoints = 48 datapoint
+ */
+ private static final int SAFE_HOURS_NUMBER_FOR_MINUTELY = 24;
+ /**
+ * As for hourly / daily / weekly interval, we should use maximum datapoints for those period
+ * eg.:
+ * - max minutely : when `minuteValue` == 1 minute-interval -> 12 HOURS
+ * when `minuteValue` > 1 minute-interval -> 24 HOURS
+ * - max hourly : 1000 hours = 1000 datapoint within hourly period
+ * - max daily : 1000 days = 1000 datapoint within the day period
+ * - max weekly : 1000 days = 1000 datapoint within the day period
+ *
+ * 1000 days = 1000 datapoint for daily basis time interval
+ */
+ private static final int SAFE_HOURS_NUMBER_FOR_HOURLY = 1000;
+ private static final int SAFE_DAYS_NUMBER_FOR_DAILY = 1000;
+
+
+ /**
+ * Get safe request of requested start and end date.
+ * This is required to avoid 1000++ datapoints error. Through this way, we will create another
+ * request chunk per 12 hours with an asumptions that :
+ * 12 hours * 1 minute datapoints = 720 datapoint
+ * @param startDate Date of start time request
+ * @param endDate Date of end time request
+ * @param timeInterval {@link TimeInterval} that specified by client
+ * @return List of pair of start and end time
+ */
+ public List> getSafeRequest(long startDate, long endDate, TimeInterval timeInterval) {
+ long diffMillis = endDate - startDate;
+ final long safeHours = getSafeHours(timeInterval);
+ List> request = new ArrayList<>();
+ if (diffMillis <= TimeUnit.HOURS.toMillis(safeHours)) {
+ request.add(Pair.create(startDate, endDate));
+ return request;
+ }
+
+ long start = startDate;
+ Pair valuePairCalAddition = getValuePairCalAddition(timeInterval);
+
+ while (start < endDate) {
+ Calendar cal = Calendar.getInstance();
+ cal.setTimeInMillis(start);
+ cal.add(valuePairCalAddition.first, valuePairCalAddition.second);
+
+ long relativeEndTime = cal.getTimeInMillis();
+ long spanRelStartTime = getStartOfDay(relativeEndTime);
+
+ if (relativeEndTime > spanRelStartTime) {
+ relativeEndTime = spanRelStartTime;
+ }
+
+ if (relativeEndTime > endDate) relativeEndTime = endDate;
+
+ request.add(Pair.create(start, relativeEndTime));
+ start = relativeEndTime;
+ }
+ return request;
+ }
+
+ /**
+ * Get safe hours for a given {@link TimeInterval}
+ * @param timeInterval {@link TimeInterval}
+ * @return Total hours
+ */
+ private long getSafeHours(@NonNull TimeInterval timeInterval) {
+ TimeUnit timeUnit = timeInterval.getTimeUnit();
+ final int safeNumber = getValuePairCalAddition(timeInterval).second;
+ if (timeUnit == TimeUnit.DAYS) {
+ return TimeUnit.DAYS.toHours(safeNumber);
+ }
+ return safeNumber;
+ }
+
+ /**
+ * Get pair of value addition for calendar within available safe number.
+ * @param timeInterval Given time interval
+ * @return Pair of calendar data field and addition value
+ */
+ private Pair getValuePairCalAddition(TimeInterval timeInterval) {
+ if (timeInterval.getTimeUnit() == TimeUnit.DAYS) {
+ return Pair.create(Calendar.DATE, SAFE_DAYS_NUMBER_FOR_DAILY);
+ }
+ if (timeInterval.getTimeUnit() == TimeUnit.HOURS) {
+ return Pair.create(Calendar.HOUR_OF_DAY, SAFE_HOURS_NUMBER_FOR_HOURLY);
+ }
+ if (timeInterval.getTimeUnit() == TimeUnit.MINUTES) {
+ switch (timeInterval.getValue()) {
+ case 1 : // 1 minute interval
+ case 10 : // 10 minute interval
+ case 30 : // 30 minute interval
+ default : return Pair.create(Calendar.HOUR_OF_DAY, SAFE_HOURS_NUMBER_FOR_MINUTELY);
+ }
+ }
+ return Pair.create(Calendar.HOUR_OF_DAY, SAFE_HOURS_NUMBER_FOR_MINUTELY);
+ }
+
+ /**
+ * Get time stamp of begining of the day of the given anchor time.
+ * eg.:
+ * when `anchorTime` = '2018-10-01 00:07:00' -> `beginingOfDay` = '2018-10-01 00:00:00'
+ * when `anchorTime` = '2018-10-01 23:00:12' -> `beginingOfDay` = '2018-10-01 00:00:00'
+ * and so on
+ * @param anchorTime anchor time
+ * @return Time of end of day of the anchor time.
+ */
+ private long getStartOfDay(long anchorTime) {
+ Calendar cal = Calendar.getInstance();
+ cal.setTimeInMillis(anchorTime);
+ cal.set(Calendar.HOUR_OF_DAY, 0);
+ cal.set(Calendar.MINUTE, 0);
+ cal.set(Calendar.SECOND, 0);
+ cal.set(Calendar.MILLISECOND, 0);
+ return cal.getTimeInMillis();
+ }
+}
diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/StepCountHistoryTask.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/StepCountHistoryTask.java
new file mode 100644
index 0000000..99cdc6c
--- /dev/null
+++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/history/StepCountHistoryTask.java
@@ -0,0 +1,172 @@
+package nl.sense.rninputkit.inputkit.googlefit.history;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Pair;
+
+import com.google.android.gms.fitness.data.DataPoint;
+import com.google.android.gms.fitness.data.DataSource;
+import com.google.android.gms.fitness.data.DataType;
+import com.google.android.gms.fitness.result.DataReadResponse;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import nl.sense.rninputkit.inputkit.Options;
+import nl.sense.rninputkit.inputkit.entity.IKValue;
+import nl.sense.rninputkit.inputkit.entity.Step;
+import nl.sense.rninputkit.inputkit.entity.StepContent;
+
+class StepCountHistoryTask extends HistoryTaskFactory {
+ private DataNormalizer normalizer = new DataNormalizer() {
+ @NonNull
+ @Override
+ protected void setValueItems(@NonNull IKValue