diff --git a/android/.project b/android/.project new file mode 100644 index 0000000..0e0a1ba --- /dev/null +++ b/android/.project @@ -0,0 +1,17 @@ + + + android_ + Project android_ created by Buildship. + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.buildship.core.gradleprojectnature + + diff --git a/android/.settings/org.eclipse.buildship.core.prefs b/android/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..e889521 --- /dev/null +++ b/android/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir= +eclipse.preferences.version=1 diff --git a/android/build.gradle b/android/build.gradle index 5dd382b..b2ee783 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -49,6 +49,9 @@ android { targetSdkVersion safeExtGet('targetSdkVersion', DEFAULT_TARGET_SDK_VERSION) versionCode 1 versionName "1.0" + //set to true to allow debugging mode, false otherwise + buildConfigField 'boolean', 'IS_NOTIFICATION_DEBUG_ENABLED', "false" + buildConfigField 'boolean', 'IS_DEBUG_MODE_ENABLED', "false" } lintOptions { abortOnError false @@ -73,6 +76,16 @@ repositories { dependencies { //noinspection GradleDynamicVersion implementation 'com.facebook.react:react-native:+' // From node_modules + + // added by xavi + implementation "androidx.appcompat:appcompat:${safeExtGet('androidxVersion', '1.0.2')}" + implementation 'com.google.code.gson:gson:2.8.5' + implementation "com.google.android.gms:play-services-location:${safeExtGet('playServiceVersion', '16.0.0')}" + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'androidx.appcompat:appcompat:1.0.2' + implementation "com.google.android.gms:play-services-fitness:${safeExtGet('fitnessApiVersion', '16.0.1')}" + implementation "com.google.android.gms:play-services-awareness:${safeExtGet('awarenessApiVersion', '16.0.0')}" + implementation "com.google.android.gms:play-services-auth:${safeExtGet('authApiVersion', '16.0.1')}" } def configureReactNativePom(def pom) { @@ -82,7 +95,7 @@ def configureReactNativePom(def pom) { name packageJson.title artifactId packageJson.name version = packageJson.version - group = "com.reactlibrary" + group = "nl.sense.rninputkit" description packageJson.description url packageJson.repository.baseUrl diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2282996 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Dec 19 15:10:51 CET 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..cccdd3d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/libs/samsung-health-data-v1.3.0.jar b/android/libs/samsung-health-data-v1.3.0.jar new file mode 100755 index 0000000..9b21934 Binary files /dev/null and b/android/libs/samsung-health-data-v1.3.0.jar differ diff --git a/android/libs/sdk-v1.0.0.jar b/android/libs/sdk-v1.0.0.jar new file mode 100755 index 0000000..744bc4f Binary files /dev/null and b/android/libs/sdk-v1.0.0.jar differ diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 20c90c4..6ed2838 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,4 +1,52 @@ + package="nl.sense.rninputkit"> + + + + + + + + + + + > + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/java/com/reactlibrary/InputKitModule.java b/android/src/main/java/com/reactlibrary/InputKitModule.java deleted file mode 100644 index d9a89c1..0000000 --- a/android/src/main/java/com/reactlibrary/InputKitModule.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.reactlibrary; - -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.Callback; - -public class InputKitModule extends ReactContextBaseJavaModule { - - private final ReactApplicationContext reactContext; - - public InputKitModule(ReactApplicationContext reactContext) { - super(reactContext); - this.reactContext = reactContext; - } - - @Override - public String getName() { - return "InputKit"; - } - - @ReactMethod - public void sampleMethod(String stringArgument, int numberArgument, Callback callback) { - // TODO: Implement some actually useful functionality - callback.invoke("Received numberArgument: " + numberArgument + " stringArgument: " + stringArgument); - } -} diff --git a/android/src/main/java/com/reactlibrary/InputKitPackage.java b/android/src/main/java/com/reactlibrary/InputKitPackage.java deleted file mode 100644 index b4e7bcb..0000000 --- a/android/src/main/java/com/reactlibrary/InputKitPackage.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.reactlibrary; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import com.facebook.react.ReactPackage; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.uimanager.ViewManager; -import com.facebook.react.bridge.JavaScriptModule; - -public class InputKitPackage implements ReactPackage { - @Override - public List createNativeModules(ReactApplicationContext reactContext) { - return Arrays.asList(new InputKitModule(reactContext)); - } - - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } -} diff --git a/android/src/main/java/nl/sense/rninputkit/RNInputKitPackage.java b/android/src/main/java/nl/sense/rninputkit/RNInputKitPackage.java new file mode 100644 index 0000000..b00788e --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/RNInputKitPackage.java @@ -0,0 +1,39 @@ +package nl.sense.rninputkit; + +import nl.sense.rninputkit.modules.HealthBridge; +import nl.sense.rninputkit.modules.LoggerBridge; +import nl.sense.rninputkit.modules.health.event.EventHandler; +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Created by ahmadmuhsin on 5/24/17. + */ + +public class RNInputKitPackage implements ReactPackage { + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new HealthBridge(reactContext)); + modules.add(new LoggerBridge(reactContext)); + modules.add(new EventHandler(reactContext)); + return modules; + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + public List> createJSModules() { + return Collections.emptyList(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/data/Constants.java b/android/src/main/java/nl/sense/rninputkit/data/Constants.java new file mode 100644 index 0000000..d171389 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/data/Constants.java @@ -0,0 +1,26 @@ +package nl.sense.rninputkit.data; + +import java.util.EnumMap; + +/** + * Created by kurniaeliazar on 3/20/17. + */ + +public class Constants { + public enum EVENTS { + actionTrigger, requestSessionId, + inputKitUpdates, inputKitTracking + } + + public static final EnumMap JS_SUPPORTED_EVENTS = new EnumMap<>(EVENTS.class); + static { + JS_SUPPORTED_EVENTS.put(EVENTS.actionTrigger, "ACTION_DID_TRIGGER"); + JS_SUPPORTED_EVENTS.put(EVENTS.requestSessionId, "REQUEST_VALID_SESSION_ID"); + JS_SUPPORTED_EVENTS.put(EVENTS.inputKitUpdates, "inputKitUpdates"); + JS_SUPPORTED_EVENTS.put(EVENTS.inputKitTracking, "inputKitTracking"); + } + + /** Used by Input Kits */ + public static final int REQUEST_RESOLVE_ERROR = 999; + public static final int REQ_REQUIRED_PERMISSIONS = 101; +} diff --git a/android/src/main/java/nl/sense/rninputkit/data/ProviderName.java b/android/src/main/java/nl/sense/rninputkit/data/ProviderName.java new file mode 100644 index 0000000..0d3c8eb --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/data/ProviderName.java @@ -0,0 +1,9 @@ +package nl.sense.rninputkit.data; + +public class ProviderName { + private ProviderName() { } + + /** Available Health Provider */ + public static final String GOOGLE_FIT = "googleFit"; + // TODO: Add another health provider when it's needed +} diff --git a/android/src/main/java/nl/sense/rninputkit/helper/BloodPressureConverter.java b/android/src/main/java/nl/sense/rninputkit/helper/BloodPressureConverter.java new file mode 100644 index 0000000..01a9e3d --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/helper/BloodPressureConverter.java @@ -0,0 +1,41 @@ +package nl.sense.rninputkit.helper; + +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; + +import java.util.List; + +import nl.sense.rninputkit.inputkit.entity.BloodPressure; // TODO IMPORTS + +/** + * Created by xedi on 10/16/17. + */ + +public class BloodPressureConverter extends DataConverter { + + public WritableArray toWritableMap(@Nullable List bloodPressures) { + WritableArray array = Arguments.createArray(); + if (bloodPressures == null || bloodPressures.isEmpty()) return array; + + for (BloodPressure bp : bloodPressures) { + array.pushMap(toWritableMap(bp)); + } + return array; + } + + private WritableMap toWritableMap(@Nullable BloodPressure bp) { + WritableMap map = Arguments.createMap(); + if (bp == null) return map; + + map.putMap("time", toWritableMap(bp.getTimeRecord())); + map.putInt("systolic", bp.getSystolic()); + map.putInt("diastolic", bp.getDiastolic()); + map.putDouble("mean", bp.getMean()); + map.putInt("pulse", bp.getPulse()); + map.putString("comment", bp.getComment()); + return map; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/helper/BundleJSONConverter.java b/android/src/main/java/nl/sense/rninputkit/helper/BundleJSONConverter.java new file mode 100644 index 0000000..e2b2b26 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/helper/BundleJSONConverter.java @@ -0,0 +1,186 @@ +package nl.sense.rninputkit.helper; + +import android.os.Bundle; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + + +/** + * com.facebook.internal is solely for the use of other packages within the Facebook SDK for + * Android. Use of any of the classes in this package is unsupported, and they may be modified or + * removed without warning at any time. + * + * A helper class that can round trip between JSON and Bundle objects that contains the types: + * Boolean, Integer, Long, Double, String + * If other types are found, an IllegalArgumentException is thrown. + */ +public class BundleJSONConverter { + private static final Map, Setter> SETTERS = new HashMap<>(); + + static { + SETTERS.put(Boolean.class, new Setter() { + public void setOnBundle(Bundle bundle, String key, Object value) { + bundle.putBoolean(key, (Boolean) value); + } + + public void setOnJSON(JSONObject json, String key, Object value) throws JSONException { + json.put(key, value); + } + }); + SETTERS.put(Integer.class, new Setter() { + public void setOnBundle(Bundle bundle, String key, Object value) { + bundle.putInt(key, (Integer) value); + } + + public void setOnJSON(JSONObject json, String key, Object value) throws JSONException { + json.put(key, value); + } + }); + SETTERS.put(Long.class, new Setter() { + public void setOnBundle(Bundle bundle, String key, Object value) { + bundle.putLong(key, (Long) value); + } + + public void setOnJSON(JSONObject json, String key, Object value) throws JSONException { + json.put(key, value); + } + }); + SETTERS.put(Double.class, new Setter() { + public void setOnBundle(Bundle bundle, String key, Object value) { + bundle.putDouble(key, (Double) value); + } + + public void setOnJSON(JSONObject json, String key, Object value) throws JSONException { + json.put(key, value); + } + }); + SETTERS.put(String.class, new Setter() { + public void setOnBundle(Bundle bundle, String key, Object value) { + bundle.putString(key, (String) value); + } + + public void setOnJSON(JSONObject json, String key, Object value) throws JSONException { + json.put(key, value); + } + }); + SETTERS.put(String[].class, new Setter() { + public void setOnBundle(Bundle bundle, String key, Object value) { + throw new IllegalArgumentException("Unexpected type from JSON"); + } + + public void setOnJSON(JSONObject json, String key, Object value) throws JSONException { + JSONArray jsonArray = new JSONArray(); + for (String stringValue : (String[]) value) { + jsonArray.put(stringValue); + } + json.put(key, jsonArray); + } + }); + + SETTERS.put(JSONArray.class, new Setter() { + public void setOnBundle(Bundle bundle, String key, Object value) throws JSONException { + JSONArray jsonArray = (JSONArray) value; + ArrayList stringArrayList = new ArrayList(); + // Empty list, can't even figure out the type, assume an ArrayList + if (jsonArray.length() == 0) { + bundle.putStringArrayList(key, stringArrayList); + return; + } + + // Only strings are supported for now + for (int i = 0; i < jsonArray.length(); i++) { + Object current = jsonArray.get(i); + if (current instanceof String) { + stringArrayList.add((String) current); + } else { + throw new IllegalArgumentException("Unexpected type in an array: " + current.getClass()); + } + } + bundle.putStringArrayList(key, stringArrayList); + } + + @Override + public void setOnJSON(JSONObject json, String key, Object value) throws JSONException { + throw new IllegalArgumentException("JSONArray's are not supported in bundles."); + } + }); + } + + public interface Setter { + void setOnBundle(Bundle bundle, String key, Object value) throws JSONException; + void setOnJSON(JSONObject json, String key, Object value) throws JSONException; + } + + public static JSONObject convertToJSON(Bundle bundle) throws JSONException { + JSONObject json = new JSONObject(); + + for (String key : bundle.keySet()) { + Object value = bundle.get(key); + if (value == null) { + // Null is not supported. + continue; + } + + // Special case List as getClass would not work, since List is an interface + if (value instanceof List) { + JSONArray jsonArray = new JSONArray(); + @SuppressWarnings("unchecked") + List listValue = (List) value; + for (String stringValue : listValue) { + jsonArray.put(stringValue); + } + json.put(key, jsonArray); + continue; + } + + // Special case Bundle as it's one way, on the return it will be JSONObject + if (value instanceof Bundle) { + json.put(key, convertToJSON((Bundle) value)); + continue; + } + + Setter setter = SETTERS.get(value.getClass()); + if (setter == null) { + throw new IllegalArgumentException("Unsupported type: " + value.getClass()); + } + setter.setOnJSON(json, key, value); + } + + return json; + } + + public static Bundle convertToBundle(JSONObject jsonObject) throws JSONException { + Bundle bundle = new Bundle(); + @SuppressWarnings("unchecked") + Iterator jsonIterator = jsonObject.keys(); + while (jsonIterator.hasNext()) { + String key = jsonIterator.next(); + Object value = jsonObject.get(key); + if (value == null || value == JSONObject.NULL) { + // Null is not supported. + continue; + } + + // Special case JSONObject as it's one way, on the return it would be Bundle. + if (value instanceof JSONObject) { + bundle.putBundle(key, convertToBundle((JSONObject) value)); + continue; + } + + Setter setter = SETTERS.get(value.getClass()); + if (setter == null) { + throw new IllegalArgumentException("Unsupported type: " + value.getClass()); + } + setter.setOnBundle(bundle, key, value); + } + + return bundle; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/helper/DataConverter.java b/android/src/main/java/nl/sense/rninputkit/helper/DataConverter.java new file mode 100644 index 0000000..906b2e5 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/helper/DataConverter.java @@ -0,0 +1,30 @@ +package nl.sense.rninputkit.helper; + +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; + +import nl.sense.rninputkit.inputkit.entity.DateContent; // TODO IMPORTS + +/** + * Created by xedi on 10/13/17. + */ + +public class DataConverter { + private static final String EPOCH_PROPS = "timestamp"; + private static final String STRING_PROPS = "formattedString"; + /** + * Helper function to convert date content into writable map + * @param dateContent {@link DateContent} + * @return {@link WritableMap} + */ + protected WritableMap toWritableMap(@Nullable DateContent dateContent) { + WritableMap map = Arguments.createMap(); + if (dateContent == null) return map; + + map.putDouble(EPOCH_PROPS, dateContent.getEpoch()); + map.putString(STRING_PROPS, dateContent.getString()); + return map; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/helper/LoggerFileWriter.java b/android/src/main/java/nl/sense/rninputkit/helper/LoggerFileWriter.java new file mode 100644 index 0000000..42ae105 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/helper/LoggerFileWriter.java @@ -0,0 +1,102 @@ +package nl.sense.rninputkit.helper; + +import android.content.Context; +import android.os.Environment; +import android.util.Log; + +import nl.sense.rninputkit.BuildConfig; +import nl.sense.rninputkit.R; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; + + +/** + * Created by panji on 22/02/18. + */ + +public class LoggerFileWriter { + private final Context context; + private static final String TAG = "LoggerFileWriter"; + private File logFile; + + public LoggerFileWriter(Context context) { + this.context = context; + if (BuildConfig.IS_DEBUG_MODE_ENABLED) { + initializeLogFile(); + } + } + + /** + * Log an event into file logger + * + * @param timeStamp Define a timestamp of recent event + * @param tag Define a tag of recent event + * @param message Define a message of recent event + * @throws IOException + */ + public void logEvent(long timeStamp, String tag, String message) { + if (BuildConfig.IS_DEBUG_MODE_ENABLED) { + try { + appendToLogFile(timeStamp + ": [" + tag + "]: " + message); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + /** + * Initialising log file on external storage + */ + private void initializeLogFile() { + File logFileDirectory = new File(Environment.getExternalStorageDirectory(), "sense"); + logFile = new File(logFileDirectory, String.format("%s-input-kit.log.txt", + context.getString(R.string.app_name))); + if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + Log.d(TAG, "initializeLogFile: Storage unavailable (probably mounted elsewhere)"); + return; + } + if (!logFileDirectory.exists() && !logFileDirectory.mkdirs()) { + Log.d(TAG, "initializeLogFile: Could not create the directory for log file"); + return; + } + if (!logFile.exists()) { + try { + createLogFile(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + /** + * Append an information into log file + * + * @param line Define a message that we want to append to the log file + * @throws IOException whenever something went wrong during writing to the log file + */ + private void appendToLogFile(String line) throws IOException { + Writer writer = null; + try { + writer = new OutputStreamWriter(new FileOutputStream(logFile, true), "UTF-8"); + writer.append(line).append("\n"); + writer.flush(); + } finally { + if (writer != null) writer.close(); + } + } + + /** + * Create new log file if it doesn't exists on local storage + * + * @throws IOException whenever something went wrong during creating a new log file + */ + private void createLogFile() throws IOException { + if (!logFile.createNewFile()) { + Log.d(TAG, "createLogFile: Could not create log file for writing"); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/helper/UtilHelper.java b/android/src/main/java/nl/sense/rninputkit/helper/UtilHelper.java new file mode 100644 index 0000000..bdf84e0 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/helper/UtilHelper.java @@ -0,0 +1,99 @@ +package nl.sense.rninputkit.helper; + +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.ReadableType; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Created by kurniaeliazar on 3/22/17. + */ + +public class UtilHelper { + /** + * Converts a react native readable map into a JSON object. + * + * @param readableMap map to convert to JSON Object + * @return JSON Object that contains the readable map properties + */ + @Nullable + public static JSONObject readableMapToJson(ReadableMap readableMap) { + JSONObject jsonObject = new JSONObject(); + + if (readableMap == null) { + return null; + } + + ReadableMapKeySetIterator iterator = readableMap.keySetIterator(); + if (!iterator.hasNextKey()) { + return null; + } + + while (iterator.hasNextKey()) { + String key = iterator.nextKey(); + ReadableType readableType = readableMap.getType(key); + + try { + switch (readableType) { + case Null: + jsonObject.put(key, null); + break; + case Boolean: + jsonObject.put(key, readableMap.getBoolean(key)); + break; + case Number: + // Can be int or double. + jsonObject.put(key, readableMap.getDouble(key)); + break; + case String: + jsonObject.put(key, readableMap.getString(key)); + break; + case Map: + jsonObject.put(key, readableMapToJson(readableMap.getMap(key))); + break; + case Array: + jsonObject.put(key, convertArrayToJson(readableMap.getArray(key))); + default: + // Do nothing and fail silently + } + } catch (JSONException ex) { + // Do nothing and fail silently + } + } + + return jsonObject; + } + + public static JSONArray convertArrayToJson(ReadableArray readableArray) throws JSONException { + JSONArray array = new JSONArray(); + for (int i = 0; i < readableArray.size(); i++) { + switch (readableArray.getType(i)) { + case Boolean: + array.put(readableArray.getBoolean(i)); + break; + case Number: + array.put(readableArray.getDouble(i)); + break; + case String: + array.put(readableArray.getString(i)); + break; + case Map: + array.put(readableMapToJson(readableArray.getMap(i))); + break; + case Array: + array.put(convertArrayToJson(readableArray.getArray(i))); + break; + case Null: + default: + break; + } + } + return array; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/helper/ValueConverter.java b/android/src/main/java/nl/sense/rninputkit/helper/ValueConverter.java new file mode 100644 index 0000000..0119197 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/helper/ValueConverter.java @@ -0,0 +1,119 @@ +package nl.sense.rninputkit.helper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.util.List; + +import nl.sense.rninputkit.inputkit.entity.DateContent; // TODO IMPORTS +import nl.sense.rninputkit.inputkit.entity.IKValue; // TODO IMPORTS + +/** + * Created by panjiyudasetya on 10/23/17. + */ + +public class ValueConverter { + private ValueConverter() { } + private static final Gson GSON = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); + private static final String START_DATE_PROPS = "startDate"; + private static final String END_DATE_PROPS = "endDate"; + private static final String VALUE_PROPS = "value"; + private static final String EPOCH_PROPS = "timestamp"; + private static final String STRING_PROPS = "formattedString"; + + /** + * Helper function to convert detected value into writable map + * @param value Detected value + * @return {@link WritableMap} + */ + public static WritableMap toWritableMap(@Nullable IKValue value) { + WritableMap map = Arguments.createMap(); + if (value == null) return map; + + map.putMap(START_DATE_PROPS, toWritableMap(value.getStartDate())); + map.putMap(END_DATE_PROPS, toWritableMap(value.getEndDate())); + + Object objValue = value.getValue(); + if (objValue == null) { + map.putNull(VALUE_PROPS); + } else { + if (objValue instanceof Integer) { + map.putInt(VALUE_PROPS, (Integer) objValue); + } else if (objValue instanceof Double) { + map.putDouble(VALUE_PROPS, (Double) objValue); + } else if (objValue instanceof Float) { + map.putDouble(VALUE_PROPS, ((Float) objValue).doubleValue()); + } else if (objValue instanceof Long) { + map.putDouble(VALUE_PROPS, ((Long) objValue).doubleValue()); + } else if (objValue instanceof String) { + map.putString(VALUE_PROPS, (String) objValue); + } else if (objValue instanceof List) { + map = putListToMap((List) objValue, map); + } else { + map.putString(VALUE_PROPS, GSON.toJson(objValue)); + } + } + return map; + } + + /** + * Helper function to convert value list into {@link WritableArray} + * @param values Detected values + * @return {@link WritableArray} + */ + public static WritableArray toWritableArray(@Nullable List> values) { + WritableArray array = Arguments.createArray(); + if (values == null || values.size() == 0) return array; + + for (Object value : values) { + if (value instanceof IKValue) { + array.pushMap(toWritableMap((IKValue) value)); + } + } + return array; + } + + /** + * Helper function to put generic list to data into writable map + * @param list Generic object list + * @param map {@link WritableMap} target + */ + private static WritableMap putListToMap(@Nullable List list, + @NonNull WritableMap map) { + if (list == null || list.isEmpty()) { + map.putArray(VALUE_PROPS, Arguments.createArray()); + return map; + } + + WritableArray valueArray = Arguments.createArray(); + Object object = list.get(0); + if (object instanceof IKValue) { + for (Object value : list) { + valueArray.pushMap(toWritableMap((IKValue) value)); + } + map.putArray(VALUE_PROPS, valueArray); + } else map.putString(VALUE_PROPS, GSON.toJson(list)); + + return map; + } + + /** + * Helper function to convert date content into writable map + * @param dateContent {@link DateContent} + * @return {@link WritableMap} + */ + private static WritableMap toWritableMap(@Nullable DateContent dateContent) { + WritableMap map = Arguments.createMap(); + if (dateContent == null) return map; + + map.putDouble(EPOCH_PROPS, dateContent.getEpoch()); + map.putString(STRING_PROPS, dateContent.getString()); + return map; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/helper/WeightConverter.java b/android/src/main/java/nl/sense/rninputkit/helper/WeightConverter.java new file mode 100644 index 0000000..0d0dc61 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/helper/WeightConverter.java @@ -0,0 +1,39 @@ +package nl.sense.rninputkit.helper; + +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; + +import java.util.List; + +import nl.sense.rninputkit.inputkit.entity.Weight; // TODO IMPORTS + +/** + * Created by xedi on 10/13/17. + */ + +public class WeightConverter extends DataConverter { + + public WritableArray toWritableMap(@Nullable List 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 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 aggregateType; + private OnCompleteListener onCompleteListener; + private OnFailureListener onFailureListener; + private HistoryResponseSet responseSet; + protected Options options; + + protected HistoryTaskFactory(IFitReader fitDataReader, + List> safeRequests, + Options options, + DataType dataTypeRequest, + Pair 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 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 currentItem, + @Nullable IKValue nextItem, + @NonNull List> sourceValues) { + this.setAsInt(currentItem, nextItem, sourceValues); + } + }; + + private HistoryExtractor extractor = new HistoryExtractor() { + @Override + protected Integer getDataPointValue(@Nullable DataPoint dataPoint) { + return this.asInt(dataPoint); + } + }; + + private StepCountHistoryTask(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); + } + return normalizer.normalize(options.getStartTime(), + options.getEndTime(), fitValues, options.getTimeInterval()); + } + + /** + * Convert input kit integer values into step content + * @param values input kit integer values + * @param startTime start time of content + * @param endTime end time of content + * @return Step content + */ + public static StepContent toStepContent(List> values, long startTime, long endTime) { + List steps = new ArrayList<>(); + if (values != null) { + for (IKValue value : values) { + steps.add(new Step( + value.getValue(), + value.getStartDate().getEpoch(), + value.getEndDate().getEpoch()) + ); + } + } + return new StepContent( + true, + startTime, + endTime, + steps + ); + } + + 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 addAggregateSourceType(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."); + } + + StepCountHistoryTask build() { + validate(); + return new StepCountHistoryTask( + fitDataReader, + safeRequests, + options, + dataTypeRequest, + aggregateType, + onCompleteListener, + onFailureListener + ); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/DistanceSensor.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/DistanceSensor.java new file mode 100644 index 0000000..11fcc49 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/DistanceSensor.java @@ -0,0 +1,35 @@ +package nl.sense.rninputkit.inputkit.googlefit.sensor; + +import android.content.Context; +import androidx.annotation.NonNull; + +import com.google.android.gms.fitness.data.DataSource; +import com.google.android.gms.fitness.data.DataType; +import com.google.android.gms.fitness.request.OnDataPointListener; + +import java.util.concurrent.TimeUnit; + +/** + * Created by panjiyudasetya on 10/23/17. + */ + +public class DistanceSensor extends SensorApi { + + public DistanceSensor(@NonNull Context context) { + super(context); + } + + void setOptions(int samplingRate, + @NonNull TimeUnit samplingTimeUnit, + @NonNull OnDataPointListener listener) { + + SensorOptions options = new SensorOptions + .Builder() + .dataType(DataType.TYPE_DISTANCE_CUMULATIVE, DataSource.TYPE_DERIVED) + .samplingRate(samplingRate) + .samplingTimeUnit(samplingTimeUnit) + .sensorListener(listener) + .build(); + setOptions(options); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorApi.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorApi.java new file mode 100644 index 0000000..051d110 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorApi.java @@ -0,0 +1,182 @@ +package nl.sense.rninputkit.inputkit.googlefit.sensor; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.auth.api.signin.GoogleSignIn; +import com.google.android.gms.fitness.Fitness; +import com.google.android.gms.fitness.data.DataSource; +import com.google.android.gms.fitness.request.SensorRequest; +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 nl.sense.rninputkit.inputkit.HealthProvider.SensorListener; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +/** + * Created by panjiyudasetya on 6/15/17. + */ +@SuppressWarnings("weakReference") +public abstract class SensorApi { + private SensorOptions mOptions; + private Context mContext; + + public SensorApi(@NonNull Context context) { + mContext = context; + } + + /** + * Set sensor api options + * @param options {@link SensorOptions} + */ + protected void setOptions(@NonNull SensorOptions options) { + mOptions = options; + } + + /** + * Subscribing relevant Sensor + * @param listener sensor listener + * @throws IllegalStateException whenever {@link SensorApi#mOptions} unspecified. + * Make sure to call {@link SensorApi#setOptions(SensorOptions)} before subscribing. + */ + public void subscribe(@NonNull final SensorListener listener) { + if (mOptions == null) throw new IllegalStateException("Sensor options unspecified!"); + + Fitness.getSensorsClient(mContext, GoogleSignIn.getLastSignedInAccount(mContext)) + .findDataSources(mOptions.getDataSourcesRequest()) + .addOnSuccessListener(new OnSuccessListener>() { + @Override + public void onSuccess(List dataSources) { + DataSource dataSource = findDataSource(dataSources); + if (dataSource == null) { + String message = "No Data sources available for " + mOptions.getDataType().getName(); + IKResultInfo errorInfo = new IKResultInfo( + IKStatus.Code.INVALID_REQUEST, + message + ); + listener.onSubscribe(errorInfo); + return; + } + + registerSensorListener(dataSource, listener); + } + }) + .addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + IKResultInfo errorInfo = new IKResultInfo( + IKStatus.Code.INVALID_REQUEST, + e.getMessage() + ); + listener.onSubscribe(errorInfo); + } + }); + } + + /** + * Stop subscribing data from relevant Sensor synchronously + * @throws IllegalStateException whenever {@link SensorApi#mOptions} unspecified. + * Make sure to call {@link SensorApi#setOptions(SensorOptions)} before unsubscribing. + */ + public Task unsubscribe() { + if (mOptions == null) throw new IllegalStateException("Sensor options unspecified!"); + + return Fitness.getSensorsClient(mContext, GoogleSignIn.getLastSignedInAccount(mContext)) + .remove(mOptions.getSensorListener()); + } + + /** + * Stop subscribing data from relevant Sensor + * @param listener sensor listener + * @throws IllegalStateException whenever {@link SensorApi#mOptions} unspecified. + * Make sure to call {@link SensorApi#setOptions(SensorOptions)} before unsubscribing. + */ + public void unsubscribe(@NonNull final SensorListener listener) { + if (mOptions == null) throw new IllegalStateException("Sensor options unspecified!"); + + Fitness.getSensorsClient(mContext, GoogleSignIn.getLastSignedInAccount(mContext)) + .remove(mOptions.getSensorListener()) + .addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Boolean isSuccess) { + if (isSuccess) { + IKResultInfo info = new IKResultInfo( + IKStatus.Code.VALID_REQUEST, + "Successfully remove sensor listener."); + listener.onUnsubscribe(info); + } + } + }) + .addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + IKResultInfo info = new IKResultInfo( + IKStatus.Code.INVALID_REQUEST, + e.getMessage()); + listener.onUnsubscribe(info); + } + }); + } + + /** + * Helper function to create Sensor Request on specific {@link DataSource} + * @param dataSource Sensor {@link DataSource} + * @return {@link SensorRequest} + */ + private SensorRequest buildSensorRequest(@NonNull DataSource dataSource) { + return new SensorRequest.Builder() + .setDataSource(dataSource) + .setDataType(mOptions.getDataType()) + .setSamplingRate(mOptions.getSamplingRate(), mOptions.getSamplingTimeUnit()) + .build(); + } + + /** + * Helper function to register sensor listener into Sensor API + * @param dataSource Sensor {@link DataSource} + * @param listener sensor listener + */ + private void registerSensorListener(@NonNull DataSource dataSource, + @NonNull final SensorListener listener) { + SensorRequest request = buildSensorRequest(dataSource); + Fitness.getSensorsClient(mContext, GoogleSignIn.getLastSignedInAccount(mContext)) + .add(request, mOptions.getSensorListener()) + .addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Void aVoid) { + IKResultInfo info = new IKResultInfo( + IKStatus.Code.VALID_REQUEST, + "Successfully added sensor listener"); + listener.onSubscribe(info); + } + }) + .addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + IKResultInfo info = new IKResultInfo( + IKStatus.Code.INVALID_REQUEST, + e.getMessage()); + listener.onUnsubscribe(info); + } + }); + } + + /** + * Helper function to find a correct {@link DataSource} for relevant sensor. + * @param dataSourcesResult {@link DataSource} collection + * @return {@link DataSource} + */ + private DataSource findDataSource(@Nullable List dataSourcesResult) { + if (dataSourcesResult == null) return null; + for (DataSource dataSource : dataSourcesResult) { + if (mOptions.getDataType().equals(dataSource.getDataType())) + return dataSource; + } + return null; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorManager.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorManager.java new file mode 100644 index 0000000..edbd536 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorManager.java @@ -0,0 +1,304 @@ +package nl.sense.rninputkit.inputkit.googlefit.sensor; + +import android.content.Context; +import androidx.annotation.NonNull; +import android.util.Pair; + +import com.google.android.gms.fitness.data.DataPoint; +import com.google.android.gms.fitness.data.Field; +import com.google.android.gms.fitness.data.Value; +import com.google.android.gms.fitness.request.OnDataPointListener; +import com.google.android.gms.tasks.Continuation; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.OnSuccessListener; +import com.google.android.gms.tasks.Task; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import nl.sense.rninputkit.inputkit.HealthProvider.SensorListener; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.constant.SampleType; +import nl.sense.rninputkit.inputkit.constant.SampleType.SampleName; +import nl.sense.rninputkit.inputkit.entity.DateContent; +import nl.sense.rninputkit.inputkit.entity.IKValue; +import nl.sense.rninputkit.inputkit.entity.SensorDataPoint; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +import static nl.sense.rninputkit.inputkit.constant.DataSampling.DEFAULT_SAMPLING_TIME_UNIT; +import static nl.sense.rninputkit.inputkit.constant.DataSampling.DEFAULT_TIME_SAMPLING_RATE; + +/** + * Created by panjiyudasetya on 7/24/17. + */ + +public class SensorManager { + private StepSensor mStepSensor; + private DistanceSensor mDistanceSensor; + private Context mContext; + private Map> mSensorListeners; + // Step tracking data point listener + private final OnDataPointListener mStepDataPointListener = new OnDataPointListener() { + @Override + public void onDataPoint(DataPoint dataPoint) { + SensorListener listener = mSensorListeners.get(SampleType.STEP_COUNT); + if (dataPoint != null && listener != null) { + listener.onReceive( + fromDataPoint( + SampleType.STEP_COUNT, + dataPoint + ) + ); + } + } + }; + // Distance walking or running data point listener + private final OnDataPointListener mDistanceDataPointListener = new OnDataPointListener() { + @Override + public void onDataPoint(DataPoint dataPoint) { + SensorListener listener = mSensorListeners.get(SampleType.DISTANCE_WALKING_RUNNING); + if (dataPoint != null && listener != null) { + listener.onReceive( + fromDataPoint( + SampleType.DISTANCE_WALKING_RUNNING, + dataPoint + ) + ); + } + } + }; + + public SensorManager(@NonNull Context context) { + mContext = context; + mStepSensor = new StepSensor(mContext); + mDistanceSensor = new DistanceSensor(mContext); + mSensorListeners = new HashMap<>(); + } + + /** + * Register sensor API listener + * @param sampleType sensor type name + * @param listener sensor data point listener + */ + @SuppressWarnings("unused") + public void registerListener(@NonNull @SampleName String sampleType, + @NonNull SensorListener listener) { + mSensorListeners.put(sampleType, listener); + } + + /** + * Start sensor tracking Api based on specific sensor. + * @param sampleType available 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. + */ + @SuppressWarnings("unused") + public void startTracking(@NonNull @SampleName String sampleType, + @NonNull Pair samplingRate) { + if (sampleType.equals(SampleType.STEP_COUNT)) { + startStepTracking(samplingRate); + } else if (sampleType.equals(SampleType.DISTANCE_WALKING_RUNNING)) { + startDistanceTracking(samplingRate); + } + } + + /** + * Stop sensor tracking Api based on specific sensor. + * @param sampleType available sensor + */ + @SuppressWarnings("unused") + public void stopTracking(@NonNull @SampleName String sampleType) { + if (sampleType.equals(SampleType.STEP_COUNT)) { + stopStepTracking(); + } else if (sampleType.equals(SampleType.DISTANCE_WALKING_RUNNING)) { + stopDistanceTracking(); + } + } + + /** + * Stop all sensor tracking. + */ + @SuppressWarnings("unused") + public void stopTrackingAll(@NonNull final SensorListener listener) { + mStepSensor.unsubscribe() + .continueWithTask(new Continuation>() { + @Override + public Task then(@NonNull Task task) { + return mDistanceSensor.unsubscribe(); + } + }) + .addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Boolean result) { + handleResponse(true, + listener, + SampleType.STEP_COUNT, + SampleType.DISTANCE_WALKING_RUNNING); + } + }) + .addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + handleResponse(false, + listener, + SampleType.STEP_COUNT, + SampleType.DISTANCE_WALKING_RUNNING); + } + }); + } + + private void handleResponse(boolean isSuccess, + @NonNull SensorListener listener, + @NonNull String... sensorTypes) { + String message; + int status; + if (isSuccess) { + status = IKStatus.Code.VALID_REQUEST; + message = String.format("%s sensor samples has been stopped.", + Arrays.toString(sensorTypes)); + } else { + status = IKStatus.Code.INVALID_REQUEST; + message = String.format("%s sensor samples has been stopped.", + Arrays.toString(sensorTypes)); + } + listener.onUnsubscribe(new IKResultInfo(status, message)); + } + + /** + * Helper function to start step count sensor api + * @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. + */ + private void startStepTracking(@NonNull Pair samplingRate) { + final SensorListener listener = mSensorListeners.get(SampleType.STEP_COUNT); + if (listener == null) { + String message = getStartFailureMessage(SampleType.STEP_COUNT); + throw new IllegalStateException(message); + } + + int rate = (samplingRate.first == null || samplingRate.first <= 0) + ? DEFAULT_TIME_SAMPLING_RATE : samplingRate.first; + TimeUnit timeUnit = samplingRate.second == null + ? DEFAULT_SAMPLING_TIME_UNIT : samplingRate.second; + mStepSensor.setOptions(rate, timeUnit, mStepDataPointListener); + mStepSensor.subscribe(listener); + } + + /** + * Helper function to stop step count sensor api + */ + private void stopStepTracking() { + final SensorListener listener = mSensorListeners.get(SampleType.STEP_COUNT); + if (listener == null) { + String message = getStartFailureMessage(SampleType.STEP_COUNT); + throw new IllegalStateException(message); + } + + mStepSensor.unsubscribe(listener); + } + + /** + * Helper function to start distance walking or running sensor api + * @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. + */ + private void startDistanceTracking(@NonNull Pair samplingRate) { + final SensorListener listener = mSensorListeners.get(SampleType.DISTANCE_WALKING_RUNNING); + if (listener == null) { + String message = getStartFailureMessage(SampleType.DISTANCE_WALKING_RUNNING); + throw new IllegalStateException(message); + } + + int rate = (samplingRate.first == null || samplingRate.first <= 0) + ? DEFAULT_TIME_SAMPLING_RATE : samplingRate.first; + TimeUnit timeUnit = samplingRate.second == null + ? DEFAULT_SAMPLING_TIME_UNIT : samplingRate.second; + mDistanceSensor.setOptions(rate, timeUnit, mDistanceDataPointListener); + + mDistanceSensor.subscribe(listener); + } + + /** + * Helper function to stop distance walking or running sensor api + */ + private void stopDistanceTracking() { + final SensorListener listener = mSensorListeners.get(SampleType.DISTANCE_WALKING_RUNNING); + if (listener == null) { + String message = getStartFailureMessage(SampleType.DISTANCE_WALKING_RUNNING); + throw new IllegalStateException(message); + } + + mDistanceSensor.unsubscribe(listener); + } + + /** + * Helper function to generate failure message + * @param sensorType sensor type name + * @return failure message + */ + private String getStartFailureMessage(@NonNull @SampleName String sensorType) { + return "UNABLE TO PERFORM THIS ACTION!\n" + + "Please do register sensor listener for " + sensorType + + " before starting to monitor this event."; + } + + /** + * Return payload collections from given data point. + * @param sensorType sensor type name + * @param dataPoint Event data point + * @return {@link SensorDataPoint} + */ + private static SensorDataPoint fromDataPoint(@NonNull @SampleName String sensorType, + @NonNull DataPoint dataPoint) { + + List> payloads = new ArrayList<>(); + SensorDataPoint output = new SensorDataPoint( + sensorType, + Collections.>emptyList() + ); + + if (dataPoint.getDataType() == null) return output; + + List fields = dataPoint.getDataType().getFields(); + if (fields == null || fields.isEmpty()) return output; + + for (Field field : fields) { + Value value = dataPoint.getValue(field); + int format = value.getFormat(); + switch (format) { + case Field.FORMAT_FLOAT: + payloads.add(new IKValue<>( + value.asFloat(), + new DateContent(dataPoint.getStartTime(TimeUnit.MILLISECONDS)), + new DateContent(dataPoint.getEndTime(TimeUnit.MILLISECONDS))) + ); + break; + case Field.FORMAT_INT32: + payloads.add(new IKValue<>( + value.asInt(), + new DateContent(dataPoint.getStartTime(TimeUnit.MILLISECONDS)), + new DateContent(dataPoint.getEndTime(TimeUnit.MILLISECONDS))) + ); + break; + case Field.FORMAT_STRING: + default: + payloads.add(new IKValue<>( + value.asString(), + new DateContent(dataPoint.getStartTime(TimeUnit.MILLISECONDS)), + new DateContent(dataPoint.getEndTime(TimeUnit.MILLISECONDS))) + ); + break; + } + } + output.setPayload(payloads); + return output; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorOptions.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorOptions.java new file mode 100644 index 0000000..2a200de --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/SensorOptions.java @@ -0,0 +1,105 @@ +package nl.sense.rninputkit.inputkit.googlefit.sensor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.fitness.data.DataSource; +import com.google.android.gms.fitness.data.DataType; +import com.google.android.gms.fitness.request.DataSourcesRequest; +import com.google.android.gms.fitness.request.OnDataPointListener; + +import java.util.concurrent.TimeUnit; + +import static nl.sense.rninputkit.inputkit.constant.DataSampling.DEFAULT_SAMPLING_TIME_UNIT; +import static nl.sense.rninputkit.inputkit.constant.DataSampling.DEFAULT_TIME_SAMPLING_RATE; +import static nl.sense.rninputkit.inputkit.googlefit.sensor.Validator.validateDataType; +import static nl.sense.rninputkit.inputkit.googlefit.sensor.Validator.validateSensorListener; + +/** + * Created by panjiyudasetya on 6/15/17. + */ + +public class SensorOptions { + private DataType mDataType; + private DataSourcesRequest mDataSourcesRequest; + private int mSamplingRate; + private TimeUnit mSamplingTimeUnit; + private OnDataPointListener mSensorListener; + + private SensorOptions(@NonNull DataType dataType, + @NonNull DataSourcesRequest dataSourcesRequest, + int timeSampling, + @NonNull TimeUnit samplingTimeUnit, + @NonNull OnDataPointListener sensorListener) { + mDataType = dataType; + mDataSourcesRequest = dataSourcesRequest; + mSamplingRate = timeSampling; + mSamplingTimeUnit = samplingTimeUnit; + mSensorListener = sensorListener; + } + + public DataType getDataType() { + return mDataType; + } + + public DataSourcesRequest getDataSourcesRequest() { + return mDataSourcesRequest; + } + + public int getSamplingRate() { + return mSamplingRate; + } + + public TimeUnit getSamplingTimeUnit() { + return mSamplingTimeUnit; + } + + public OnDataPointListener getSensorListener() { + return mSensorListener; + } + + public static class Builder { + private DataType newDataType; + private DataSourcesRequest newDataSourcesRequest; + private int newSamplingRate; + private TimeUnit newSamplingTimeUnit; + private OnDataPointListener newSensorListener; + + public Builder dataType(@NonNull DataType dataType, int dataSourceType) { + newDataType = dataType; + newDataSourcesRequest = new DataSourcesRequest.Builder() + .setDataTypes(dataType) + .setDataSourceTypes(dataSourceType < 0 ? DataSource.TYPE_RAW : dataSourceType) + .build(); + return this; + } + + public Builder samplingRate(int samplingRate) { + newSamplingRate = samplingRate; + return this; + } + + public Builder samplingTimeUnit(@Nullable TimeUnit samplingTimeUnit) { + newSamplingTimeUnit = samplingTimeUnit; + return this; + } + + public Builder sensorListener(@NonNull OnDataPointListener sensorListener) { + newSensorListener = sensorListener; + return this; + } + + public SensorOptions build() { + validateDataType(newDataType); + validateSensorListener(newSensorListener); + + return new SensorOptions( + newDataType, + newDataSourcesRequest, + newSamplingRate == 0 ? DEFAULT_TIME_SAMPLING_RATE : newSamplingRate, + newSamplingTimeUnit == null ? DEFAULT_SAMPLING_TIME_UNIT : newSamplingTimeUnit, + newSensorListener + ); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/StepSensor.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/StepSensor.java new file mode 100644 index 0000000..2600873 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/StepSensor.java @@ -0,0 +1,35 @@ +package nl.sense.rninputkit.inputkit.googlefit.sensor; + +import android.content.Context; +import androidx.annotation.NonNull; + +import com.google.android.gms.fitness.data.DataSource; +import com.google.android.gms.fitness.data.DataType; +import com.google.android.gms.fitness.request.OnDataPointListener; + +import java.util.concurrent.TimeUnit; + +/** + * Created by panjiyudasetya on 7/20/17. + */ + +public class StepSensor extends SensorApi { + + public StepSensor(@NonNull Context context) { + super(context); + } + + void setOptions(int samplingRate, + @NonNull TimeUnit samplingTimeUnit, + @NonNull OnDataPointListener listener) { + + SensorOptions options = new SensorOptions + .Builder() + .dataType(DataType.TYPE_STEP_COUNT_DELTA, DataSource.TYPE_DERIVED) + .samplingRate(samplingRate) + .samplingTimeUnit(samplingTimeUnit) + .sensorListener(listener) + .build(); + setOptions(options); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/Validator.java b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/Validator.java new file mode 100644 index 0000000..15578d9 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/googlefit/sensor/Validator.java @@ -0,0 +1,25 @@ +package nl.sense.rninputkit.inputkit.googlefit.sensor; + +import com.google.android.gms.fitness.data.DataType; +import com.google.android.gms.fitness.request.OnDataPointListener; + +/** + * Created by panjiyudasetya on 6/15/17. + */ + +@SuppressWarnings("SpellCheckingInspection") +public class Validator { + private Validator() { } + + public static void validateDataType(DataType dataType) { + if (dataType == null) { + throw new IllegalStateException("Sensor data type must be provided!"); + } + } + + public static void validateSensorListener(OnDataPointListener listener) { + if (listener == null) { + throw new IllegalStateException("Sensor listener must be provided!"); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/helper/AppHelper.java b/android/src/main/java/nl/sense/rninputkit/inputkit/helper/AppHelper.java new file mode 100644 index 0000000..900110e --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/helper/AppHelper.java @@ -0,0 +1,77 @@ +package nl.sense.rninputkit.inputkit.helper; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import androidx.annotation.NonNull; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +import static nl.sense.rninputkit.inputkit.constant.RequiredApp.GOOGLE_FIT_PACKAGE_NAME; + +/** + * Created by panjiyudasetya on 9/26/17. + */ + +public class AppHelper { + private AppHelper() { } + /** + * Check whether Google Fit application is installed on the device or not. + * @param context Current application context + * @return True if installed False otherwise + */ + public static boolean isGoogleFitInstalled(@NonNull Context context) { + try { + context.getPackageManager().getPackageInfo(GOOGLE_FIT_PACKAGE_NAME, PackageManager.GET_ACTIVITIES); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + /** + * Check whether Google Play service is up to date or not + * @param context Current application context + * @return True if up-to-date, False otherwise + */ + public static boolean isPlayServiceUpToDate(@NonNull Context context) { + GoogleApiAvailability googleApiAvailability = GoogleApiAvailability.getInstance(); + Integer resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context); + return resultCode != ConnectionResult.SUCCESS ? false : true; + } + + /** + * Open required application package in playstore if available + * @param context Current application context + * @param packageId Application package id + */ + public static void openInPlayStore(@NonNull Context context, @NonNull String packageId) { + final String LINK_TO_GOOGLE_PLAY_SERVICES = "play.google.com/store/apps/details?id=" + packageId + "&hl=en"; + try { + context.startActivity(new Intent( + Intent.ACTION_VIEW, + Uri.parse("market://" + LINK_TO_GOOGLE_PLAY_SERVICES) + )); + } catch (ActivityNotFoundException e) { + context.startActivity(new Intent( + Intent.ACTION_VIEW, + Uri.parse("https://" + LINK_TO_GOOGLE_PLAY_SERVICES) + )); + } + } + + /** + * Launch another application in phone + * @param context Current application context + * @param packageId Application package id + */ + public static void openAnotherApp(@NonNull Context context, @NonNull String packageId) { + Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageId); + if (launchIntent != null) { + context.startActivity(launchIntent); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/helper/CollectionUtils.java b/android/src/main/java/nl/sense/rninputkit/inputkit/helper/CollectionUtils.java new file mode 100644 index 0000000..f78d825 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/helper/CollectionUtils.java @@ -0,0 +1,55 @@ +package nl.sense.rninputkit.inputkit.helper; + +import androidx.annotation.NonNull; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import nl.sense.rninputkit.inputkit.entity.IKValue; + +/** + * Created by panjiyudasetya on 7/3/17. + */ + +public class CollectionUtils { + /** + * Helper function to sort steps collections. + * + * @param ascending Set to True to use ascending sort, + * False to use descending + * @param values Input kit values + */ + public static void sort(boolean ascending, @NonNull List> values) { + // create comparator based on sorted type + Comparator comparator; + if (ascending) { + comparator = new Comparator() { + @Override + public int compare(IKValue ikValue1, IKValue ikValue2) { + return compareLong(ikValue1.getStartDate().getEpoch(), ikValue2.getStartDate().getEpoch()); + } + }; + } else { + comparator = new Comparator() { + @Override + public int compare(IKValue ikValue1, IKValue ikValue2) { + return compareLong(ikValue2.getStartDate().getEpoch(), ikValue1.getStartDate().getEpoch()); + } + }; + } + + Collections.sort(values, comparator); + } + + /** + * Helper function to compare two long values + * @param value1 First value to compare + * @param value2 Second value to compare + * @return int result + */ + @SuppressWarnings("PMD") // Long.compare(value1, value2) is no available on API 16 + private static int compareLong(Long value1, Long value2) { + return value1.compareTo(value2); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/helper/InputKitTimeUtils.java b/android/src/main/java/nl/sense/rninputkit/inputkit/helper/InputKitTimeUtils.java new file mode 100644 index 0000000..6600958 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/helper/InputKitTimeUtils.java @@ -0,0 +1,238 @@ +package nl.sense.rninputkit.inputkit.helper; + +import androidx.annotation.NonNull; +import android.util.Pair; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import nl.sense.rninputkit.inputkit.InputKit.Result; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.entity.TimeInterval; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +/** + * Created by panjiyudasetya on 6/19/17. + */ + +public class InputKitTimeUtils { + public static final long ONE_DAY = 24 * 60 * 60 * 1000; + + private InputKitTimeUtils() { + } + + /** + * Get minute difference between two timestamp values. + * @param timeStamp1 First timestamp + * @param timeStamp2 Second timestamp + * @return A minute difference + */ + public static long getMinuteDiff(long timeStamp1, long timeStamp2) { + // Make sure to exclude milliseconds calculation + timeStamp1 = timeStamp1 / 1000 * 1000; + timeStamp2 = timeStamp2 / 1000 * 1000; + return Math.abs(TimeUnit.MILLISECONDS.toMinutes(timeStamp2 - timeStamp1)); + } + + /** + * Helper function to detect whether given start and end time are overlapping time window. + * @param startTime Start time + * @param endTime End time + * @param time Time window + * @return True if overlapping time window, False otherwise. + */ + public static boolean isOverlappingTimeWindow(long startTime, + long endTime, + @NonNull Pair time) { + return (startTime < time.first && endTime >= time.first) + || (startTime < time.second && endTime >= time.second); + } + + /** + * Helper function to detect whether given start and end time are within time window or not. + * + * @param startTime Start time + * @param endTime End time + * @param time Time window + * @return True if within time window, False otherwise. + */ + public static boolean isWithinTimeWindow(long startTime, + long endTime, + @NonNull Pair time) { + return isWithinTimeWindow(startTime, time) && isWithinTimeWindow(endTime, time); + } + + /** + * Check is a given time within time period or not. + * @param time1 Timestamp that needs to be checked + * @param timePeriod Bound of time period + * @return True if a given time within time period, False otherwise. + */ + public static boolean isWithinTimeWindow(long time1, @NonNull Pair timePeriod) { + return time1 >= timePeriod.first && time1 < timePeriod.second; + } + + /** + * Helper function to populate time window based on specific range and {@link TimeInterval}. + * For instance, to get time window for each ten minute starting from specific time, it can be + * achieved by call : + *

{@code
+     *
+     *     Pair timeRange = InputKitTimeUtils.populateOneDayRangeBeforeGivenTime(
+     *          new Date().getTimeInMillis()
+     *     );
+     *
+     *     List> timeWindow = InputKitTimeUtils.populateTimeWindows(
+     *          timeRange.first,
+     *          timeRange.second,
+     *          new TimeInterval({@link nl.sense.rninputkit.inputkit.constant.Interval#TEN_MINUTE})
+     *     );
+     * }
+ * Then the output should be like (in human readable format) : + *
{@code
+     *
+     *     [{"2017-06-13 12:40:00", "2017-06-13 12:50:00"}, {"2017-06-13 12:30:00", "2017-06-13 12:40:00"}]
+     * }
+ * + * @param startTime Start time + * @param endTime End time + * @param interval {@link TimeInterval} + * @return Time window + */ + public static List> populateTimeWindows(long startTime, + long endTime, + @NonNull TimeInterval interval) { + validateTimeInput(startTime, endTime); + + List> timeWindows = new ArrayList<>(); + while (startTime < endTime) { + long relativeEndTime = computeTimeWindow(startTime, interval); + if (relativeEndTime > endTime) relativeEndTime = endTime; + timeWindows.add(Pair.create(startTime, relativeEndTime)); + startTime = relativeEndTime; + } + return timeWindows; + } + + /** + * Validate given time period + * + * @param startTime Start time + * @param endTime End time + * @param callback {@link Result} callback which to be handled + * @return True if valid time period, False otherwise. + */ + public static boolean validateTimeInput(long startTime, + long endTime, + @NonNull Result callback) { + + if (!isValidTimePeriod(startTime, endTime)) { + callback.onError(new IKResultInfo( + IKStatus.Code.INVALID_REQUEST, + "Invalid time period. Start time and end time should be greater than 0!" + )); + return false; + } + + if (!isValidStartTime(startTime, endTime)) { + callback.onError(new IKResultInfo( + IKStatus.Code.INVALID_REQUEST, + "Invalid time period. Start time should less or equals than end time!" + )); + return false; + } + return true; + } + + /** + * Helper function to compute time based on {@link TimeInterval} + * + * @param anchorTime Anchor time + * @param interval {@link TimeInterval} + * @return Previous time of known end time + */ + public static long computeTimeWindow(long anchorTime, @NonNull TimeInterval interval) { + Calendar cal = Calendar.getInstance(); + cal.setTimeInMillis(anchorTime); + + // set calendar operator based on given time interval + if (interval.getTimeUnit().equals(TimeUnit.DAYS)) { + cal.add(Calendar.DAY_OF_MONTH, interval.getValue()); + } else if (interval.getTimeUnit().equals(TimeUnit.HOURS)) { + cal.add(Calendar.HOUR_OF_DAY, interval.getValue()); + } else if (interval.getTimeUnit().equals(TimeUnit.MINUTES)) { + cal.add(Calendar.MINUTE, interval.getValue()); + } else + throw new IllegalStateException("Unsupported Time Interval detected!\n" + interval.toString()); + + return cal.getTimeInMillis(); + } + + /** + * get epoch time of today in current time zone + */ + public static long getTodayStartTime() { + Calendar today = Calendar.getInstance(); + today.set(Calendar.HOUR_OF_DAY, 0); + today.set(Calendar.MINUTE, 0); + today.set(Calendar.SECOND, 0); + today.set(Calendar.MILLISECOND, 0); + return today.getTimeInMillis(); + } + + /** + * Helper function to validate input time. + * + * @param startTime Given start time + * @param endTime Given end time + * @throws {@link IllegalStateException} + */ + private static void validateTimeInput(long startTime, long endTime) { + if (!isValidTimePeriod(startTime, endTime)) { + throw new IllegalStateException("Start time and end time should be greater than 0!"); + } + if (!isValidStartTime(startTime, endTime)) { + throw new IllegalStateException("Start time should less or equals than end time!"); + } + } + + /** + * Validate time period. + * + * @param startTime Given start time + * @param endTime Given end time + * @return True if valid, false otherwise. + */ + private static boolean isValidTimePeriod(long startTime, long endTime) { + return (startTime > 0 && endTime > 0); + } + + /** + * Validate start time value. + * + * @param startTime Given start time + * @param endTime Given end time + * @return True if valid, false otherwise. + */ + private static boolean isValidStartTime(long startTime, long endTime) { + return startTime <= endTime; + } + + /** + * Helper function to convert time in human readable format + * + * @param stamp Given start time + * example output : 02-Oct-2017 12:30:00 + */ + public static String timeStampToString(long stamp) { + final String TIME_FORMAT = "dd-MMM-yyyy HH:mm:ss"; + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(stamp); + SimpleDateFormat dateFormat = new SimpleDateFormat(TIME_FORMAT, Locale.US); + return dateFormat.format(c.getTime()); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/helper/PreferenceHelper.java b/android/src/main/java/nl/sense/rninputkit/inputkit/helper/PreferenceHelper.java new file mode 100644 index 0000000..e38a23f --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/helper/PreferenceHelper.java @@ -0,0 +1,75 @@ +package nl.sense.rninputkit.inputkit.helper; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.annotation.NonNull; +import android.text.TextUtils; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Created by panjiyudasetya on 7/21/17. + */ + +public class PreferenceHelper { + private PreferenceHelper() { } + + private static final String PREFERENCE_NAME = "IK_PREFERENCE"; + + /** + * Add value string into Shared Preference. + * For non string value, just convert it into {@link String} + * For an object, use {@link Gson} to stringify the object. + * + * @param context current application context + * @param key Key preference + * @param value Value preference + */ + public static void add(@NonNull Context context, + @NonNull String key, + String value) { + SharedPreferences.Editor editor = context.getSharedPreferences( + PREFERENCE_NAME, + Context.MODE_PRIVATE + ).edit(); + editor.putString(key, value); + editor.apply(); + } + + /** + * Get value from Shared Preference. + * + * @param context current application context + * @param key Key preference + * @return value string. + */ + public static String get(@NonNull Context context, + @NonNull String key) { + SharedPreferences preferences = context.getSharedPreferences( + PREFERENCE_NAME, + Context.MODE_PRIVATE + ); + return preferences.getString(key, null); + } + + /** + * Get value from Shared Preference. + * + * @param context current application context + * @param key Key preference + * @return {@link JsonObject} jsonify value from share preference. + */ + public static JsonObject getAsJson(@NonNull Context context, + @NonNull String key) { + SharedPreferences preferences = context.getSharedPreferences( + PREFERENCE_NAME, + Context.MODE_PRIVATE + ); + String json = preferences.getString(key, null); + return TextUtils.isEmpty(json) + ? new JsonObject() + : new JsonParser().parse(json).getAsJsonObject(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/BloodPressureReader.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/BloodPressureReader.java new file mode 100644 index 0000000..637c14f --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/BloodPressureReader.java @@ -0,0 +1,80 @@ +package nl.sense.rninputkit.inputkit.shealth; + +import androidx.annotation.NonNull; + +import com.samsung.android.sdk.healthdata.HealthConstants; +import com.samsung.android.sdk.healthdata.HealthData; +import com.samsung.android.sdk.healthdata.HealthDataResolver; +import com.samsung.android.sdk.healthdata.HealthDataStore; +import com.samsung.android.sdk.healthdata.HealthResultHolder; + +import java.util.ArrayList; +import java.util.List; + +import nl.sense.rninputkit.inputkit.InputKit; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.entity.BloodPressure; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +/** + * Created by xedi on 10/9/17. + */ + +public class BloodPressureReader { + private final HealthDataResolver mResolver; + + public BloodPressureReader(HealthDataStore store) { + mResolver = new HealthDataResolver(store, null); + } + + public void readBloodPressure(final long startTime, final long stopTime, + @NonNull final InputKit.Result> callback) { + try { + HealthDataResolver.ReadRequest request = createRequest(startTime, stopTime); + mResolver.read(request).setResultListener( + new HealthResultHolder.ResultListener() { + @Override + public void onResult(HealthDataResolver.ReadResult healthDatas) { + List bloodPressureList = new ArrayList(); + try { + for (HealthData data : healthDatas) { + Long time = data.getLong(HealthConstants.BloodPressure.START_TIME); + Integer sys = data.getInt(HealthConstants.BloodPressure.SYSTOLIC); + Integer dia = data.getInt(HealthConstants.BloodPressure.DIASTOLIC); + Float mean = data.getFloat(HealthConstants.BloodPressure.MEAN); + Integer pulse = data.getInt(HealthConstants.BloodPressure.PULSE); + String comment = data.getString(HealthConstants.BloodPressure.COMMENT); + + BloodPressure bp = new BloodPressure(sys, dia, time); + bp.setMean(mean); + bp.setPulse(pulse); + bp.setComment(comment); + bloodPressureList.add(bp); + } + } finally { + callback.onNewData(bloodPressureList); + healthDatas.close(); + } + } + }); + } catch (Exception e) { + callback.onError(new IKResultInfo(IKStatus.Code.UNKNOWN_ERROR, e.getMessage())); + } + } + + private HealthDataResolver.ReadRequest createRequest(long startTime, long stopTime) { + return new HealthDataResolver.ReadRequest.Builder() + .setDataType(HealthConstants.BloodPressure.HEALTH_DATA_TYPE) + .setProperties(new String[]{HealthConstants.BloodPressure.DEVICE_UUID, + HealthConstants.BloodPressure.START_TIME, + HealthConstants.BloodPressure.SYSTOLIC, + HealthConstants.BloodPressure.DIASTOLIC, + HealthConstants.BloodPressure.MEAN, + HealthConstants.BloodPressure.PULSE, + HealthConstants.BloodPressure.COMMENT}) + .setLocalTimeRange(HealthConstants.BloodPressure.START_TIME, HealthConstants.BloodPressure.TIME_OFFSET, + startTime, stopTime) + .setSort(HealthConstants.BloodPressure.START_TIME, HealthDataResolver.SortOrder.DESC) + .build(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SHealthConstant.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SHealthConstant.java new file mode 100644 index 0000000..396316d --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SHealthConstant.java @@ -0,0 +1,42 @@ +package nl.sense.rninputkit.inputkit.shealth; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Created by xedi on 9/25/17. + */ + +public class SHealthConstant { + public static final String STEP_COUNT = "com.samsung.health.step_count"; + public static final String STEP_DAILY_TREND = "com.samsung.shealth.step_daily_trend"; + + public static final int STATUS_CONNECTED = 0; + public static final int STATUS_DISCONNECTED = 1; + public static final int STATUS_ERROR = 2; + public static final int STATUS_ERROR_INIT = 3; + + public static final int PERMISSION_GRANTED = 0; + public static final int PERMISSION_DENIED = 1; + + public static final long ONE_MINUTE = 60 * 1000; + public static final long ONE_HOUR = ONE_MINUTE * 60; + public static final long ONE_DAY = ONE_HOUR * 24; + + public static final String ASLEEP = "asleep"; + public static final String AWAKE = "awake"; + public static final String IN_BED = "inBed"; + + public static final List SUPPORTED_DATA_TYPES = + new ArrayList<>(Arrays.asList("step_count", + "step_history", + "sleep", + "weight", + "blood_pressure")); + + public static final String DATE_FORMAT_DAILY = "yyyy-MM-dd"; + public static final String DATE_FORMAT_HOURLY = "yyyy-MM-dd HH"; + public static final String DATE_FORMAT_MINUTELY = "yyyy-MM-dd HH:mm"; + +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SHealthWrapper.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SHealthWrapper.java new file mode 100644 index 0000000..0ba3e94 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SHealthWrapper.java @@ -0,0 +1,308 @@ +package nl.sense.rninputkit.inputkit.shealth; + +import android.content.Context; +import androidx.annotation.NonNull; +import android.util.Log; + +import com.samsung.android.sdk.healthdata.HealthConnectionErrorResult; +import com.samsung.android.sdk.healthdata.HealthDataService; +import com.samsung.android.sdk.healthdata.HealthDataStore; +import com.samsung.android.sdk.healthdata.HealthPermissionManager; +import com.samsung.android.sdk.healthdata.HealthResultHolder; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import nl.sense.rninputkit.inputkit.HealthProvider; +import nl.sense.rninputkit.inputkit.InputKit; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +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.Options; +import nl.sense.rninputkit.inputkit.shealth.utils.SHealthPermissionSet; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + + +/** + * Created by xedi on 9/20/17. + */ + +public class SHealthWrapper { + public static final String TAG = "SHealthWrapper"; + private HealthDataStore mStore; + private StepCountReader mStepReader; + private SleepReader mSleepReader; + private BloodPressureReader mBloodPressureReader; + private WeightReader mWeightReader; + private SHealthPermissionSet mPermissionSet; + + private boolean mFinished = true; + private int mConnectionStatus = SHealthConstant.STATUS_DISCONNECTED; + private HealthConnectionErrorResult mLastConnectionError = null; + private OnConnectCallback mCurrentConnectCallback = null; + private final HealthDataStore.ConnectionListener mConnectionListener = + new HealthDataStore.ConnectionListener() { + @Override + public void onConnected() { + mConnectionStatus = SHealthConstant.STATUS_CONNECTED; + mLastConnectionError = null; + if (mCurrentConnectCallback != null) { + mCurrentConnectCallback.onResult(mConnectionStatus, null); + } + Log.d(TAG, "onConnected"); + } + + @Override + public void onConnectionFailed(HealthConnectionErrorResult error) { + mConnectionStatus = SHealthConstant.STATUS_ERROR; + mLastConnectionError = error; + if (mCurrentConnectCallback != null) { + mCurrentConnectCallback.onResult(mConnectionStatus, mLastConnectionError); + } + Log.d(TAG, "onConnectionFailed"); + } + + @Override + public void onDisconnected() { + mConnectionStatus = SHealthConstant.STATUS_DISCONNECTED; + mLastConnectionError = null; + if (mCurrentConnectCallback != null) { + mCurrentConnectCallback.onResult(mConnectionStatus, null); + } + Log.d(TAG, "onDisconnected"); + if (!isFinishing()) { + mStore.connectService(); + } + } + }; + private OnPermissionCallback mCurrentPermissionCallback = null; + private final HealthResultHolder.ResultListener mPermissionListener = + new HealthResultHolder.ResultListener() { + @Override + public void onResult(HealthPermissionManager.PermissionResult result) { + Map resultMap = result.getResultMap(); + if (resultMap.values().contains(Boolean.FALSE)) { + mCurrentPermissionCallback.onResult(SHealthConstant.PERMISSION_DENIED); + } else { + mCurrentPermissionCallback.onResult(SHealthConstant.PERMISSION_GRANTED); + } + } + }; + + public SHealthWrapper(Context context) { + mFinished = false; + mPermissionSet = SHealthPermissionSet.getInstance(); + initialize(context); + initReporter(); + } + + private void initialize(Context context) { + HealthDataService healthDataService = new HealthDataService(); + try { + healthDataService.initialize(context); + } catch (Exception e) { + e.printStackTrace(); + mConnectionStatus = SHealthConstant.STATUS_ERROR_INIT; + } + mStore = new HealthDataStore(context, mConnectionListener); + mStore.connectService(); + } + + public void connectService(OnConnectCallback connectCallback) { + mCurrentConnectCallback = connectCallback; + if (mConnectionStatus == SHealthConstant.STATUS_CONNECTED) { + connectCallback.onResult(mConnectionStatus, mLastConnectionError); + } else { + mStore.connectService(); + } + } + + public void disconnect() { + mConnectionStatus = SHealthConstant.STATUS_DISCONNECTED; + mLastConnectionError = null; + mStore.disconnectService(); + } + + public boolean isFinishing() { + return mFinished; + } + + public HealthConnectionErrorResult getLastConnectionError() { + return mLastConnectionError; + } + + public int getConnectionStatus() { + return mConnectionStatus; + } + + public void authorize(OnPermissionCallback permissionCallback, + boolean forceShowPermission, + String... permissionType) { + mCurrentPermissionCallback = permissionCallback; + + if (forceShowPermission || !isPermissionAcquired(generatePermissionKeySet(permissionType))) { + requestPermission(permissionType); + return; + } + + mCurrentPermissionCallback.onResult(SHealthConstant.PERMISSION_GRANTED); + } + + public void authorize(OnPermissionCallback permissionCallback) { + authorize(permissionCallback, false); + } + + public void getStepCount(long startTime, long endTime, + @NonNull InputKit.Result callback) { + mStepReader.readStepCount(startTime, endTime, callback); + } + + public void getStepCountDistribution(Options options, int limit, + InputKit.Result callback) { + mStepReader.readStepCountHistories(options, limit, callback); + } + + public void monitorStep(String sensorType, long startTime, + @NonNull HealthProvider.SensorListener listener) { + if (!isPermissionAcquired(mPermissionSet.getStepPermissionSet())) { + listener.onUnsubscribe(new IKResultInfo(IKStatus.Code.S_HEALTH_PERMISSION_REQUIRED, + IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS)); + return; + } + + mStepReader.monitorStepData(sensorType, startTime, listener); + } + + public void stopMonitorStep(String sensorType, + @NonNull HealthProvider.SensorListener listener) { + mStepReader.stopMonitorStepData(sensorType, listener); + } + + public void getStepDistance(long startTime, long endTime, + InputKit.Result callback) { + if (!isPermissionAcquired(mPermissionSet.getStepPermissionSet())) { + callback.onError(new IKResultInfo(IKStatus.Code.S_HEALTH_PERMISSION_REQUIRED, + IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS)); + return; + } + + mStepReader.readStepDistance(startTime, endTime, callback); + } + + public void getStepDistanceSamples(long startTime, long endTime, int limit, + @NonNull InputKit.Result>> callback) { + if (!isPermissionAcquired(mPermissionSet.getStepPermissionSet())) { + callback.onError(new IKResultInfo(IKStatus.Code.S_HEALTH_PERMISSION_REQUIRED, + IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS)); + return; + } + + mStepReader.readStepDistanceSamples(startTime, endTime, limit, callback); + } + + public void getSleepAnalysisSamples(long startTime, long endTime, + @NonNull InputKit.Result>> callback) { + if (!isPermissionAcquired(mPermissionSet.getSleepPermissionSet())) { + callback.onError(new IKResultInfo(IKStatus.Code.S_HEALTH_PERMISSION_REQUIRED, + IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS)); + return; + } + + mSleepReader.readSleep(startTime, endTime, callback); + } + + public void getBloodPressure(long startTime, long endTime, + @NonNull InputKit.Result> callback) { + if (!isPermissionAcquired(mPermissionSet.getBloodPressurePermissionSet())) { + callback.onError(new IKResultInfo(IKStatus.Code.S_HEALTH_PERMISSION_REQUIRED, + IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS)); + return; + } + + mBloodPressureReader.readBloodPressure(startTime, endTime, callback); + } + + public void getWeight(long startTime, long endTime, + @NonNull InputKit.Result> callback) { + if (!isPermissionAcquired(mPermissionSet.getWeightPermissionSet())) { + callback.onError(new IKResultInfo(IKStatus.Code.S_HEALTH_PERMISSION_REQUIRED, + IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS)); + return; + } + + mWeightReader.readWeight(startTime, endTime, callback); + } + + public void getListDataType(@NonNull InputKit.Result> callback) { + callback.onNewData(SHealthConstant.SUPPORTED_DATA_TYPES); + } + + private void initReporter() { + mStepReader = new StepCountReader(mStore); + mSleepReader = new SleepReader(mStore); + mBloodPressureReader = new BloodPressureReader(mStore); + mWeightReader = new WeightReader(mStore); + } + + public boolean isPermissionAcquired(Set sets) { + if (sets == null || sets.size() == 0) { + return false; + } + + HealthPermissionManager pmsManager = new HealthPermissionManager(mStore); + try { + Map resultMap = pmsManager.isPermissionAcquired(sets); + return !resultMap.values().contains(Boolean.FALSE); + } catch (Exception e) { + Log.e(TAG, "Permission request fails.", e); + } + return false; + } + + private void requestPermission(String... permissionType) { + HealthPermissionManager pmsManager = new HealthPermissionManager(mStore); + try { + pmsManager.requestPermissions( + generatePermissionKeySet(permissionType), + null + ).setResultListener(mPermissionListener); + } catch (Exception e) { + Log.e(TAG, "Permission setting fails.", e); + mCurrentPermissionCallback.onResult(SHealthConstant.PERMISSION_DENIED); + } + } + + public Set generatePermissionKeySet(String... permissionType) { + if (permissionType == null || permissionType.length == 0) { + return Collections.emptySet(); + } + Set pmsKeySet = new HashSet<>(); + for (String permission : permissionType) { + if (permission.equals(SampleType.STEP_COUNT) || permission.equals(SampleType.DISTANCE_WALKING_RUNNING)) { + pmsKeySet.addAll(mPermissionSet.getStepPermissionSet()); + } else if (permission.equals(SampleType.SLEEP)) { + pmsKeySet.addAll(mPermissionSet.getSleepPermissionSet()); + } else if (permission.equals(SampleType.WEIGHT)) { + pmsKeySet.addAll(mPermissionSet.getWeightPermissionSet()); + } else if (permission.equals(SampleType.BLOOD_PRESSURE)) { + pmsKeySet.addAll(mPermissionSet.getBloodPressurePermissionSet()); + } + } + return pmsKeySet; + } + + public interface OnConnectCallback { + void onResult(int statusCode, HealthConnectionErrorResult errorInfo); + } + + public interface OnPermissionCallback { + void onResult(int resultCode); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SamsungHealthProvider.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SamsungHealthProvider.java new file mode 100644 index 0000000..2f44854 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SamsungHealthProvider.java @@ -0,0 +1,253 @@ +package nl.sense.rninputkit.inputkit.shealth; + +import android.app.Activity; +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.util.Pair; + +import com.samsung.android.sdk.healthdata.HealthConnectionErrorResult; +import com.samsung.android.sdk.healthdata.HealthPermissionManager; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import nl.sense.rninputkit.inputkit.HealthProvider; +import nl.sense.rninputkit.inputkit.HealthProvider; +import nl.sense.rninputkit.inputkit.InputKit; +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.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.TimeInterval; +import nl.sense.rninputkit.inputkit.entity.Weight; +import nl.sense.rninputkit.inputkit.Options; +import nl.sense.rninputkit.inputkit.helper.InputKitTimeUtils; +import nl.sense.rninputkit.inputkit.shealth.utils.SHealthUtils; +import nl.sense.rninputkit.inputkit.status.IKProviderInfo; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +/** + * Created by xedi on 10/18/17. + */ + +public class SamsungHealthProvider extends HealthProvider { + private static final IKResultInfo REQUIRED_PERMISSION = new IKResultInfo( + IKStatus.Code.S_HEALTH_PERMISSION_REQUIRED, + IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS + ); + private static final IKResultInfo DISCONNECTED = new IKResultInfo( + IKStatus.Code.S_HEALTH_DISCONNECTED, + IKStatus.INPUT_KIT_DISCONNECTED); + private static final IKResultInfo UNSUPPORTED_REALTIME_TRACKING = new IKResultInfo( + IKStatus.Code.INVALID_REQUEST, + "Sample type is not supported for real time monitoring"); + + private SHealthWrapper mSHealthWrapper; + + public SamsungHealthProvider(@NonNull Context context) { + super(context); + mSHealthWrapper = new SHealthWrapper(context); + } + + @Nullable + @Override + public Context getContext() { + return super.getContext(); + } + + @Nullable + @Override + public Activity getHostActivity() { + return super.getHostActivity(); + } + + @Override + public void setHostActivity(@Nullable Activity activity) { + super.setHostActivity(activity); + } + + @Override + protected boolean isAvailable(@NonNull final InputKit.Result callback) { + return super.isAvailable(callback); + } + + @Override + public boolean isAvailable() { + return (mSHealthWrapper.getConnectionStatus() == SHealthConstant.STATUS_CONNECTED); + } + + @Override + public boolean isPermissionsAuthorised(String[] permissionTypes) { + if (permissionTypes == null || permissionTypes.length == 0) { + return false; + } + + Set permissionSet = + mSHealthWrapper.generatePermissionKeySet(permissionTypes); + return mSHealthWrapper.isPermissionAcquired(permissionSet); + } + + @Override + public void authorize(@NonNull final InputKit.Callback callback, final String... permissionType) { + mSHealthWrapper.connectService(new SHealthWrapper.OnConnectCallback() { + @Override + public void onResult(int statusCode, HealthConnectionErrorResult errorInfo) { + if (statusCode == SHealthConstant.STATUS_CONNECTED) { + checkPermissions(callback, permissionType); + } else if (statusCode == SHealthConstant.STATUS_DISCONNECTED) { + callback.onNotAvailable(DISCONNECTED); + } else if (statusCode == SHealthConstant.STATUS_ERROR) { + int errorCode = IKStatus.Code.S_HEALTH_CONNECTION_ERROR; + String msg = IKStatus.INPUT_KIT_CONNECTION_ERROR; + if (errorInfo != null) { + errorCode = errorInfo.getErrorCode(); + msg = getErrorMessage(errorCode); + } + callback.onConnectionRefused(new IKProviderInfo(errorCode, msg)); + } + } + }); + } + + @Override + public void disconnect(@NonNull InputKit.Result callback) { + mSHealthWrapper.disconnect(); + } + + @Override + public void getDistance(long startTime, long endTime, int limit, @NonNull InputKit.Result callback) { + mSHealthWrapper.getStepDistance(adjustTimeToUTC(startTime), adjustTimeToUTC(endTime), callback); + } + + @Override + public void getDistanceSamples(long startTime, long endTime, int limit, + @NonNull InputKit.Result>> callback) { + mSHealthWrapper.getStepDistanceSamples(adjustTimeToUTC(startTime), adjustTimeToUTC(endTime), limit, callback); + } + + @Override + public void getStepCount(@NonNull InputKit.Result callback) { + long startTime = InputKitTimeUtils.getTodayStartTime(); + long endTime = startTime + InputKitTimeUtils.ONE_DAY; + mSHealthWrapper.getStepCount(startTime, endTime, callback); + } + + @Override + public void getStepCount(long startTime, long endTime, int limit, + @NonNull InputKit.Result callback) { + mSHealthWrapper.getStepCount(adjustTimeToUTC(startTime), adjustTimeToUTC(endTime), callback); + } + + @Override + public void getStepCountDistribution(long startTime, long endTime, + @NonNull @Interval.IntervalName String interval, + int limit, @NonNull InputKit.Result callback) { + TimeInterval timeInterval = new TimeInterval(interval); + Options options = new Options.Builder() + .startTime(adjustTimeToUTC(startTime)) + .endTime(adjustTimeToUTC(endTime)) + .timeInterval(timeInterval) + .useDataAggregation() + .build(); + mSHealthWrapper.getStepCountDistribution(options, limit, callback); + } + + @Override + public void getSleepAnalysisSamples(long startTime, long endTime, + @NonNull InputKit.Result>> callback) { + mSHealthWrapper.getSleepAnalysisSamples(adjustTimeToUTC(startTime), adjustTimeToUTC(endTime), callback); + } + + @Override + public void getBloodPressure(long startTime, long endTime, + @NonNull InputKit.Result> callback) { + mSHealthWrapper.getBloodPressure(adjustTimeToUTC(startTime), adjustTimeToUTC(endTime), callback); + } + + @Override + public void getWeight(long startTime, long endTime, + @NonNull InputKit.Result> callback) { + mSHealthWrapper.getWeight(adjustTimeToUTC(startTime), adjustTimeToUTC(endTime), callback); + } + + public void getListDataType(@NonNull InputKit.Result> callback) { + mSHealthWrapper.getListDataType(callback); + } + + @Override + public void startMonitoring(@NonNull @SampleType.SampleName String sensorType, + @NonNull Pair samplingRate, + @NonNull SensorListener listener) { + if (sensorType.equals(SampleType.STEP_COUNT)) { + long startTime = System.currentTimeMillis(); + mSHealthWrapper.monitorStep(sensorType, adjustTimeToUTC(startTime), listener); + } else listener.onSubscribe(UNSUPPORTED_REALTIME_TRACKING); + } + + @Override + public void stopMonitoring(@NonNull @SampleType.SampleName String sensorType, + @NonNull SensorListener listener) { + if (sensorType.equals(SampleType.STEP_COUNT)) { + mSHealthWrapper.stopMonitorStep(sensorType, listener); + } else listener.onSubscribe(UNSUPPORTED_REALTIME_TRACKING); + } + + @Override + public void startTracking(@NonNull @SampleType.SampleName String sensorType, + @NonNull Pair samplingRate, + @NonNull SensorListener listener) { + if (sensorType.equals(SampleType.STEP_COUNT)) { + long startTime = System.currentTimeMillis(); + mSHealthWrapper.monitorStep(sensorType, adjustTimeToUTC(startTime), listener); + } else listener.onSubscribe(UNSUPPORTED_REALTIME_TRACKING); + } + + @Override + public void stopTracking(@NonNull String sensorType, + @NonNull SensorListener listener) { + if (sensorType.equals(SampleType.STEP_COUNT)) { + mSHealthWrapper.stopMonitorStep(sensorType, listener); + } else listener.onSubscribe(UNSUPPORTED_REALTIME_TRACKING); + } + + @Override + public void stopTrackingAll(@NonNull SensorListener listener) { + mSHealthWrapper.stopMonitorStep("", listener); + } + + private void checkPermissions(@NonNull final InputKit.Callback callback, String... permissionType) { + mSHealthWrapper.authorize(new SHealthWrapper.OnPermissionCallback() { + @Override + public void onResult(int resultCode) { + if (resultCode == SHealthConstant.STATUS_CONNECTED) { + callback.onAvailable(); + } else if (resultCode == SHealthConstant.STATUS_DISCONNECTED) { + callback.onNotAvailable(REQUIRED_PERMISSION); + } + } + }, false, permissionType); + } + + private String getErrorMessage(int errorCode) { + switch (errorCode) { + case HealthConnectionErrorResult.PLATFORM_NOT_INSTALLED: + return IKStatus.SAMSUNG_HEALTH_NOT_INSTALLED; + case HealthConnectionErrorResult.OLD_VERSION_PLATFORM: + return IKStatus.SAMSUNG_HEALTH_OLD_VERSION; + case HealthConnectionErrorResult.PLATFORM_DISABLED: + return IKStatus.SAMSUNG_HEALTH_DISABLED; + case HealthConnectionErrorResult.USER_AGREEMENT_NEEDED: + return IKStatus.SAMSUNG_HEALTH_USER_AGREEMENT_NEEDED; + default: return IKStatus.SAMSUNG_HEALTH_IS_NOT_AVAILABLE; + } + } + + private long adjustTimeToUTC(long time) { + return time + SHealthUtils.timeDiff(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SleepReader.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SleepReader.java new file mode 100644 index 0000000..9bfd5c9 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/SleepReader.java @@ -0,0 +1,73 @@ +package nl.sense.rninputkit.inputkit.shealth; + +import androidx.annotation.NonNull; + +import com.samsung.android.sdk.healthdata.HealthConstants; +import com.samsung.android.sdk.healthdata.HealthData; +import com.samsung.android.sdk.healthdata.HealthDataResolver; +import com.samsung.android.sdk.healthdata.HealthDataStore; +import com.samsung.android.sdk.healthdata.HealthResultHolder; + +import java.util.ArrayList; +import java.util.List; + +import nl.sense.rninputkit.inputkit.InputKit; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.entity.DateContent; +import nl.sense.rninputkit.inputkit.entity.IKValue; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +/** + * Created by xedi on 10/4/17. + */ + +public class SleepReader { + private final HealthDataResolver mResolver; + + public SleepReader(HealthDataStore store) { + mResolver = new HealthDataResolver(store, null); + } + + public void readSleep(final long startTime, final long stopTime, + @NonNull final InputKit.Result>> callback) { + try { + HealthDataResolver.ReadRequest request = createRequest(startTime, stopTime); + mResolver.read(request).setResultListener( + new HealthResultHolder.ResultListener() { + @Override + public void onResult(HealthDataResolver.ReadResult healthDatas) { + List> sleepData = new ArrayList>(); + try { + for (HealthData data : healthDatas) { + Long goBed = data.getLong(HealthConstants.Sleep.START_TIME); + Long wakeUp = data.getLong(HealthConstants.Sleep.END_TIME); + IKValue sleep = new IKValue<>(SHealthConstant.ASLEEP, + new DateContent(goBed), + new DateContent(wakeUp)); + sleepData.add(sleep); + } + } finally { + callback.onNewData(sleepData); + healthDatas.close(); + } + } + }); + } catch (Exception e) { + callback.onError(new IKResultInfo(IKStatus.Code.UNKNOWN_ERROR, e.getMessage())); + } + } + + private HealthDataResolver.ReadRequest createRequest(long startTime, long stopTime) { + return new HealthDataResolver.ReadRequest.Builder() + .setDataType(HealthConstants.Sleep.HEALTH_DATA_TYPE) + .setProperties(new String[]{ + HealthConstants.Sleep.DEVICE_UUID, + HealthConstants.Sleep.START_TIME, + HealthConstants.Sleep.END_TIME, + HealthConstants.Sleep.PACKAGE_NAME}) + .setLocalTimeRange(HealthConstants.Sleep.START_TIME, + HealthConstants.Sleep.TIME_OFFSET, startTime, stopTime) + .setSort(HealthConstants.Sleep.START_TIME, HealthDataResolver.SortOrder.DESC) + .build(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepBinningData.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepBinningData.java new file mode 100644 index 0000000..9f76768 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepBinningData.java @@ -0,0 +1,17 @@ +package nl.sense.rninputkit.inputkit.shealth; + +/** + * Created by xedi on 11/16/17. + */ + +public class StepBinningData { + public final int count; + public final float distance; + public String time; + + public StepBinningData(String time, int count, float distance) { + this.time = time; + this.count = count; + this.distance = distance; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepCountReader.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepCountReader.java new file mode 100755 index 0000000..df7abb0 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepCountReader.java @@ -0,0 +1,307 @@ +/** + * Copyright (C) Sense Health BV + * modified from s-health sample + */ + +package nl.sense.rninputkit.inputkit.shealth; + + +import androidx.annotation.NonNull; +import android.util.Pair; + +import com.samsung.android.sdk.healthdata.HealthConstants; +import com.samsung.android.sdk.healthdata.HealthData; +import com.samsung.android.sdk.healthdata.HealthDataResolver; +import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateRequest; +import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateRequest.AggregateFunction; +import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateRequest.TimeGroupUnit; +import com.samsung.android.sdk.healthdata.HealthDataResolver.Filter; +import com.samsung.android.sdk.healthdata.HealthDataResolver.SortOrder; +import com.samsung.android.sdk.healthdata.HealthDataStore; +import com.samsung.android.sdk.healthdata.HealthResultHolder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import nl.sense.rninputkit.inputkit.HealthProvider; +import nl.sense.rninputkit.inputkit.InputKit; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.constant.Interval; +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.Options; +import nl.sense.rninputkit.inputkit.shealth.utils.DataMapper; +import nl.sense.rninputkit.inputkit.shealth.utils.SHealthUtils; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +public class StepCountReader { + + public static final String TAG = "S_HEALTH"; + public static final String STEP_SUMMARY_DATA_TYPE_NAME = "com.samsung.shealth.step_daily_trend"; + private static final String ALIAS_TOTAL_COUNT = "count"; + private static final String ALIAS_TOTAL_DISTANCE = "distance"; + private static final String ALIAS_DEVICE_UUID = "deviceuuid"; + private static final String ALIAS_BINNING_TIME = "binning_time"; + private final HealthDataResolver mResolver; + private Map mStepMonitorMaps = new HashMap<>(); + + public StepCountReader(HealthDataStore store) { + mResolver = new HealthDataResolver(store, null); + } + + public void readStepCount(final long startTime, final long stopTime, + @NonNull final InputKit.Result callback) { + requestStepData(startTime, stopTime, new StepRequestListener(callback) { + @Override + public void onStepCount(int count) { + callback.onNewData(count); + } + }); + } + + public void readStepCountHistories(Options options, final int limit, + @NonNull final InputKit.Result callback) { + final long startTime = options.getStartTime(); + final long endTime = options.getEndTime(); + final TimeInterval timeInterval = options.getTimeInterval(); + requestStepData(startTime, endTime, new StepRequestListener(callback) { + @Override + void onDeviceId(String deviceId) { + readStepCountHistories(startTime, endTime, limit, timeInterval, deviceId, callback); + } + }); + } + + public void readStepDistance(long startTime, long stopTime, + @NonNull final InputKit.Result callback) { + requestStepData(startTime, stopTime, new StepRequestListener(callback) { + @Override + void onStepDistance(float distance) { + callback.onNewData(distance); + } + }); + } + + public void readStepDistanceSamples(final long startTime, final long stopTime, final int limit, + @NonNull final InputKit.Result>> callback) { + requestStepData(startTime, stopTime, new StepRequestListener(callback) { + @Override + public void onDeviceId(String deviceId) { + TimeInterval timeInterval = new TimeInterval(Interval.ONE_MINUTE); + readStepDistanceHistories(startTime, stopTime, limit, timeInterval, deviceId, callback); + } + }); + } + + private void requestStepData(final long startTime, final long stopTime, + @NonNull final StepRequestListener listener) { + AggregateRequest request = aggregateStep(startTime, stopTime); + try { + mResolver.aggregate(request).setResultListener( + new HealthResultHolder.ResultListener() { + @Override + public void onResult(HealthDataResolver.AggregateResult healthDatas) { + String deviceUuid = null; + int totalCount = 0; + float totalDistance = 0.0f; + try { + Iterator iterator = healthDatas.iterator(); + if (iterator.hasNext()) { + HealthData data = iterator.next(); + deviceUuid = data.getString(ALIAS_DEVICE_UUID); + totalCount = data.getInt(ALIAS_TOTAL_COUNT); + totalDistance = data.getFloat(ALIAS_TOTAL_DISTANCE); + } + } finally { + listener.onStepCount(totalCount); + listener.onStepDistance(totalDistance); + healthDatas.close(); + } + if (deviceUuid != null) { + listener.onDeviceId(deviceUuid); + } else { + listener.onError(new IKResultInfo(IKStatus.Code.UNKNOWN_ERROR, + IKStatus.INPUT_KIT_NO_DEVICES_SOURCE)); + } + } + }); + } catch (Exception e) { + listener.onError(new IKResultInfo(IKStatus.Code.UNKNOWN_ERROR, e.getMessage())); + } + } + + public void monitorStepData(String sensorType, long startTime, + HealthProvider.SensorListener realTimeStepListener) { + if (mStepMonitorMaps.get(sensorType) == null) { + StepMonitor sm = new StepMonitor(startTime, + sensorType, + mResolver, + realTimeStepListener); + mStepMonitorMaps.put(sensorType, sm); + } else { + realTimeStepListener.onSubscribe(new IKResultInfo(IKStatus.Code.VALID_REQUEST, + IKStatus.INPUT_KIT_MONITOR_REGISTERED)); + } + } + + public void stopMonitorStepData(String sensorType, + HealthProvider.SensorListener realTimeStepListener) { + if (sensorType.equals("")) { + Iterator> it = mStepMonitorMaps.entrySet().iterator(); + while (it.hasNext()) { + StepMonitor sm = it.next().getValue(); + sm.stopMonitor(); + } + mStepMonitorMaps.clear(); + } else { + StepMonitor sm = mStepMonitorMaps.get(sensorType); + if (sm == null) { + realTimeStepListener.onSubscribe(new IKResultInfo(IKStatus.Code.VALID_REQUEST, + IKStatus.INPUT_KIT_MONITORING_NOT_AVAILABLE)); + } else { + sm.stopMonitor(); + mStepMonitorMaps.remove(sensorType); + realTimeStepListener.onSubscribe(new IKResultInfo(IKStatus.Code.VALID_REQUEST, + IKStatus.INPUT_KIT_MONITOR_REGISTERED)); + } + } + } + + private AggregateRequest aggregateStep(long startTime, long stopTime) { + return new AggregateRequest.Builder() + .setDataType(HealthConstants.StepCount.HEALTH_DATA_TYPE) + .addFunction(AggregateFunction.SUM, HealthConstants.StepCount.COUNT, ALIAS_TOTAL_COUNT) + .addFunction(AggregateFunction.SUM, HealthConstants.StepCount.DISTANCE, ALIAS_TOTAL_DISTANCE) + .addGroup(HealthConstants.StepCount.DEVICE_UUID, ALIAS_DEVICE_UUID) + .setLocalTimeRange(HealthConstants.StepCount.START_TIME, HealthConstants.StepCount.TIME_OFFSET, + startTime, stopTime) + .setSort(ALIAS_TOTAL_COUNT, SortOrder.DESC) + .build(); + } + + private AggregateRequest aggregateStepHistory(long startTime, long stopTime, + Pair interval, String deviceUuid) { + Filter filter = Filter.eq(HealthConstants.StepCount.DEVICE_UUID, deviceUuid); + return new AggregateRequest.Builder() + .setDataType(HealthConstants.StepCount.HEALTH_DATA_TYPE) + .addFunction(AggregateFunction.SUM, + HealthConstants.StepCount.COUNT, ALIAS_TOTAL_COUNT) + .addFunction(AggregateFunction.SUM, + HealthConstants.StepCount.DISTANCE, ALIAS_TOTAL_DISTANCE) + .setTimeGroup(interval.first, interval.second, + HealthConstants.StepCount.START_TIME, + HealthConstants.StepCount.TIME_OFFSET, + ALIAS_BINNING_TIME) + .setLocalTimeRange(HealthConstants.StepCount.START_TIME, + HealthConstants.StepCount.TIME_OFFSET, startTime, stopTime) + .setFilter(filter) + .setSort(ALIAS_BINNING_TIME, SortOrder.ASC) + .build(); + } + + private void readStepCountHistories(final long startTime, final long stopTime, final int limit, + TimeInterval timeInterval, String deviceUuid, + @NonNull final InputKit.Result callbackStepHistory) { + try { + final Pair ret = SHealthUtils.convertTimeInterval(timeInterval); + AggregateRequest request = aggregateStepHistory(startTime, stopTime, ret, deviceUuid); + mResolver.aggregate(request).setResultListener( + new HealthResultHolder.ResultListener() { + @Override + public void onResult(HealthDataResolver.AggregateResult healthDatas) { + List binningCountArray = new ArrayList<>(); + try { + for (HealthData data : healthDatas) { + String binningTime = data.getString(ALIAS_BINNING_TIME); + int binningCount = data.getInt(ALIAS_TOTAL_COUNT); + float binningDistance = data.getInt(ALIAS_TOTAL_DISTANCE); + + if (binningTime != null) { + binningCountArray.add(new StepBinningData(binningTime, + binningCount, + binningDistance)); + } + } + StepContent stepContent = DataMapper.convertStepCount(startTime, + stopTime, limit, ret, + binningCountArray); + callbackStepHistory.onNewData(stepContent); + } finally { + healthDatas.close(); + } + } + }); + } catch (Exception e) { + callbackStepHistory.onError(new IKResultInfo(IKStatus.Code.UNKNOWN_ERROR, + e.getMessage())); + } + } + + private void readStepDistanceHistories(final long startTime, final long stopTime, + final int limit, + TimeInterval timeInterval, String deviceUuid, + @NonNull final InputKit.Result>> callback) { + try { + final Pair ret = + SHealthUtils.convertTimeInterval(timeInterval); + AggregateRequest request = aggregateStepHistory(startTime, + stopTime, ret, + deviceUuid); + mResolver.aggregate(request).setResultListener( + new HealthResultHolder.ResultListener() { + @Override + public void onResult(HealthDataResolver.AggregateResult healthDatas) { + List binningCountArray = new ArrayList<>(); + List> distanceList = new ArrayList>(); + try { + for (HealthData data : healthDatas) { + String binningTime = data.getString(ALIAS_BINNING_TIME); + int binningCount = data.getInt(ALIAS_TOTAL_COUNT); + float binningDistance = data.getInt(ALIAS_TOTAL_DISTANCE); + + if (binningTime != null) { + binningCountArray.add(new StepBinningData(binningTime, + binningCount, + binningDistance)); + } + } + distanceList = DataMapper.convertStepDistance(ret, + limit, + binningCountArray); + } finally { + callback.onNewData(distanceList); + healthDatas.close(); + } + } + }); + } catch (Exception e) { + callback.onError(new IKResultInfo(IKStatus.Code.UNKNOWN_ERROR, e.getMessage())); + } + } + + private abstract class StepRequestListener { + final InputKit.Result resultCallback; + + StepRequestListener(InputKit.Result callback) { + this.resultCallback = callback; + } + + void onStepCount(int count) { + } + + void onStepDistance(float distance) { + } + + void onDeviceId(String deviceId) { + } + + void onError(IKResultInfo error) { + resultCallback.onError(error); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepMonitor.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepMonitor.java new file mode 100644 index 0000000..3a7cebd --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/StepMonitor.java @@ -0,0 +1,131 @@ +package nl.sense.rninputkit.inputkit.shealth; + +import android.os.Handler; +import android.os.Looper; + +import com.samsung.android.sdk.healthdata.HealthConstants; +import com.samsung.android.sdk.healthdata.HealthData; +import com.samsung.android.sdk.healthdata.HealthDataResolver; +import com.samsung.android.sdk.healthdata.HealthResultHolder; + +import java.util.Iterator; +import java.util.concurrent.TimeUnit; + +import nl.sense.rninputkit.inputkit.HealthProvider; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.entity.SensorDataPoint; +import nl.sense.rninputkit.inputkit.shealth.utils.DataMapper; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +/** + * Created by xedi on 11/7/17. + */ + +public class StepMonitor { + private static final String ALIAS_TOTAL_COUNT = "count"; + private static final String ALIAS_TOTAL_DISTANCE = "distance"; + private static final String ALIAS_DEVICE_UUID = "deviceuuid"; + + private static final int PERIOD_IN_MS = 15 * 1000; + private long mStartTime; + private HealthProvider.SensorListener mRealTimeStepListener; + private Handler mHandler = null; + private HealthDataResolver mResolver = null; + private String mSensorType; + private boolean isStopped = false; + private Runnable mTaskRunnable = new Runnable() { + @Override + public void run() { + if (!isStopped) { + executeMonitoring(); + mHandler.postDelayed(this, TimeUnit.MILLISECONDS.toMillis(PERIOD_IN_MS)); + } + } + }; + private HealthResultHolder.ResultListener mStepListener = + new HealthResultHolder.ResultListener() { + @Override + public void onResult(HealthDataResolver.AggregateResult healthDatas) { + String deviceUuid = null; + int totalCount = 0; + float totalDistance = 0.0f; + try { + Iterator iterator = healthDatas.iterator(); + if (iterator.hasNext()) { + HealthData data = iterator.next(); + deviceUuid = data.getString(ALIAS_DEVICE_UUID); + totalCount = data.getInt(ALIAS_TOTAL_COUNT); + totalDistance = data.getFloat(ALIAS_TOTAL_DISTANCE); + } + } finally { + healthDatas.close(); + } + if (mRealTimeStepListener != null) { + mRealTimeStepListener.onReceive( + DataMapper.toSensorDataPoint(mSensorType, + mStartTime, totalCount, + totalDistance)); + monitorStepDataTask(); + } + } + }; + + StepMonitor(long startTime, String sensorType, HealthDataResolver resolver, + HealthProvider.SensorListener listener) { + mStartTime = startTime; + mResolver = resolver; + mRealTimeStepListener = listener; + mSensorType = sensorType; + executeMonitoring(); + } + + private void executeMonitoring() { + long stopTime = mStartTime + SHealthConstant.ONE_DAY; + HealthDataResolver.AggregateRequest request = aggregateStep(mStartTime, stopTime); + try { + mResolver.aggregate(request).setResultListener(mStepListener); + } catch (Exception e) { + mRealTimeStepListener.onUnsubscribe( + new IKResultInfo(IKStatus.Code.UNKNOWN_ERROR, e.getMessage())); + stopMonitor(false); + } + } + + public void stopMonitor(boolean callCallback) { + if (mHandler != null) { + mHandler.removeCallbacks(mTaskRunnable); + if (callCallback && !isStopped) { + mRealTimeStepListener.onUnsubscribe( + new IKResultInfo(IKStatus.Code.VALID_REQUEST, + IKStatus.INPUT_KIT_MONITOR_UNREGISTERED)); + } + } + isStopped = true; + } + + public void stopMonitor() { + stopMonitor(true); + } + + private void monitorStepDataTask() { + if (mHandler == null) { + mHandler = new Handler(Looper.getMainLooper()); + mHandler.postDelayed(mTaskRunnable, TimeUnit.MILLISECONDS.toMillis(PERIOD_IN_MS)); + } + } + + private HealthDataResolver.AggregateRequest aggregateStep(long startTime, long stopTime) { + return new HealthDataResolver.AggregateRequest.Builder() + .setDataType(HealthConstants.StepCount.HEALTH_DATA_TYPE) + .addFunction(HealthDataResolver.AggregateRequest.AggregateFunction.SUM, + HealthConstants.StepCount.COUNT, ALIAS_TOTAL_COUNT) + .addFunction(HealthDataResolver.AggregateRequest.AggregateFunction.SUM, + HealthConstants.StepCount.DISTANCE, ALIAS_TOTAL_DISTANCE) + .addGroup(HealthConstants.StepCount.DEVICE_UUID, ALIAS_DEVICE_UUID) + .setLocalTimeRange(HealthConstants.StepCount.START_TIME, + HealthConstants.StepCount.TIME_OFFSET, + startTime, stopTime) + .setSort(ALIAS_TOTAL_COUNT, HealthDataResolver.SortOrder.DESC) + .build(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/WeightReader.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/WeightReader.java new file mode 100644 index 0000000..c230d42 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/WeightReader.java @@ -0,0 +1,78 @@ +package nl.sense.rninputkit.inputkit.shealth; + +import androidx.annotation.NonNull; + +import com.samsung.android.sdk.healthdata.HealthConstants; +import com.samsung.android.sdk.healthdata.HealthData; +import com.samsung.android.sdk.healthdata.HealthDataResolver; +import com.samsung.android.sdk.healthdata.HealthDataStore; +import com.samsung.android.sdk.healthdata.HealthResultHolder; + +import java.util.ArrayList; +import java.util.List; + +import nl.sense.rninputkit.inputkit.InputKit; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.entity.Weight; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +/** + * Created by xedi on 10/9/17. + */ + +public class WeightReader { + private final HealthDataResolver mResolver; + + public WeightReader(HealthDataStore store) { + mResolver = new HealthDataResolver(store, null); + } + + public void readWeight(final long startTime, final long stopTime, + @NonNull final InputKit.Result> callback) { + try { + HealthDataResolver.ReadRequest request = createRequest(startTime, stopTime); + mResolver.read(request).setResultListener( + new HealthResultHolder.ResultListener() { + @Override + public void onResult(HealthDataResolver.ReadResult healthDatas) { + List weightList = new ArrayList(); + try { + for (HealthData data : healthDatas) { + Long time = data.getLong(HealthConstants.Weight.START_TIME); + Float weight = data.getFloat(HealthConstants.Weight.WEIGHT); + Integer bodyFat = data.getInt(HealthConstants.Weight.BODY_FAT); + String comment = data.getString(HealthConstants.Weight.COMMENT); + + Weight weightData = new Weight(weight, bodyFat, time); + weightData.setComment(comment); + weightList.add(weightData); + } + } finally { + callback.onNewData(weightList); + healthDatas.close(); + } + } + }); + } catch (Exception e) { + callback.onError( + new IKResultInfo(IKStatus.Code.UNKNOWN_ERROR, + e.getMessage())); + } + } + + private HealthDataResolver.ReadRequest createRequest(long startTime, long stopTime) { + return new HealthDataResolver.ReadRequest.Builder() + .setDataType(HealthConstants.Weight.HEALTH_DATA_TYPE) + .setProperties(new String[]{ + HealthConstants.Weight.DEVICE_UUID, + HealthConstants.Weight.START_TIME, + HealthConstants.Weight.WEIGHT, + HealthConstants.Weight.BODY_FAT, + HealthConstants.Weight.COMMENT}) + .setLocalTimeRange(HealthConstants.Weight.START_TIME, + HealthConstants.Weight.TIME_OFFSET, startTime, stopTime) + .setSort(HealthConstants.Weight.START_TIME, + HealthDataResolver.SortOrder.DESC) + .build(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/DataMapper.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/DataMapper.java new file mode 100644 index 0000000..989147a --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/DataMapper.java @@ -0,0 +1,144 @@ +package nl.sense.rninputkit.inputkit.shealth.utils; + +import android.util.Pair; + +import com.samsung.android.sdk.healthdata.HealthDataResolver; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import nl.sense.rninputkit.inputkit.constant.SampleType; +import nl.sense.rninputkit.inputkit.entity.DateContent; +import nl.sense.rninputkit.inputkit.entity.IKValue; +import nl.sense.rninputkit.inputkit.entity.SensorDataPoint; +import nl.sense.rninputkit.inputkit.entity.Step; +import nl.sense.rninputkit.inputkit.entity.StepContent; +import nl.sense.rninputkit.inputkit.shealth.StepBinningData; + +/** + * Created by xedi on 10/19/17. + */ + +public final class DataMapper { + + public static StepContent convertStepCount(long startTime, long endTime, int limit, + Pair interval, + List stepBinningDataList) { + long timeDiff = SHealthUtils.timeDiff(); + long startTimeSrcLocal = startTime - timeDiff; + long endTimeSrcLocal = endTime - timeDiff; + long intervalInMillis = SHealthUtils.intervalToMillis(interval); + + List contents = new ArrayList<>(); + long firstDataTime = startTime; + if (!stepBinningDataList.isEmpty()) { + StepBinningData firstData = stepBinningDataList.get(0); + firstDataTime = SHealthUtils.toMillis(firstData.time, interval.first); + } + // to bottom range + long pivotTimeBott = firstDataTime; + do { + contents.add( + createStep(stepBinningDataList, + interval.first, + pivotTimeBott, + pivotTimeBott + intervalInMillis)); + pivotTimeBott = pivotTimeBott - intervalInMillis; + } while (pivotTimeBott > startTimeSrcLocal); + contents.add( + createStep(stepBinningDataList, + interval.first, + pivotTimeBott, + pivotTimeBott + intervalInMillis)); + Collections.reverse(contents); + // to top range + do { + firstDataTime = firstDataTime + intervalInMillis; + contents.add( + createStep(stepBinningDataList, + interval.first, + firstDataTime, + firstDataTime + intervalInMillis)); + } while ((firstDataTime + intervalInMillis) < endTimeSrcLocal); + + List contentLimits = new ArrayList<>(); + for (Step step : contents) { + if (outOfLimit(contentLimits.size(), limit)) { + break; + } + contentLimits.add(step); + } + return new StepContent( + true, + startTimeSrcLocal, + endTimeSrcLocal, + contentLimits + ); + } + + public static List> + convertStepDistance(Pair interval, + int limit, + List stepBinningDataList) { + long intervalInMillis = SHealthUtils.intervalToMillis(interval); + + List> distanceList = new ArrayList<>(); + for (StepBinningData sd : stepBinningDataList) { + if (outOfLimit(distanceList.size(), limit)) { + break; + } + float distanceValue = sd.distance; + long time = SHealthUtils.toMillis(sd.time, interval.first); + IKValue distance = new IKValue(distanceValue, + new DateContent(time), + new DateContent(time + intervalInMillis)); + distanceList.add(distance); + } + return distanceList; + } + + public static SensorDataPoint toSensorDataPoint(String type, + long startTime, + int step, float distance) { + List> payloads = new ArrayList<>(); + startTime = startTime - SHealthUtils.timeDiff(); + long endTime = System.currentTimeMillis(); + String topic = ""; + if (type.contains(SampleType.STEP_COUNT)) { + payloads.add(new IKValue(step, + new DateContent(startTime), + new DateContent(endTime))); + topic = SampleType.STEP_COUNT; + } + if (type.contains(SampleType.DISTANCE_WALKING_RUNNING)) { + payloads.add(new IKValue(distance, + new DateContent(startTime), + new DateContent(endTime))); + topic = topic + SampleType.DISTANCE_WALKING_RUNNING; + } + return new SensorDataPoint( + topic, + payloads + ); + } + + private static boolean outOfLimit(int size, int limit) { + return (limit > 0 && size >= limit); + } + + private static Step createStep(List stepBinningDataList, + HealthDataResolver.AggregateRequest.TimeGroupUnit tgu, + long time1, long time2) { + int stepCount = 0; + for (StepBinningData sd : stepBinningDataList) { + long time = SHealthUtils.toMillis(sd.time, tgu); + if (time1 == time) { + stepCount = sd.count; + break; + } + } + return new Step(stepCount, time1, time2); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/SHealthPermissionSet.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/SHealthPermissionSet.java new file mode 100644 index 0000000..360262d --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/SHealthPermissionSet.java @@ -0,0 +1,65 @@ +package nl.sense.rninputkit.inputkit.shealth.utils; + +import com.samsung.android.sdk.healthdata.HealthConstants; +import com.samsung.android.sdk.healthdata.HealthPermissionManager; + +import java.util.HashSet; +import java.util.Set; + +import nl.sense.rninputkit.inputkit.shealth.StepCountReader; + +public class SHealthPermissionSet { + private static SHealthPermissionSet sHealthPermissionSet; + private final Set stepPermissionSet; + private final Set sleepPermissionSet; + private final Set weightPermissionSet; + private final Set bloodPressurePermissionSet; + + SHealthPermissionSet() { + stepPermissionSet = createPermissionSet( + new String[]{ + HealthConstants.StepCount.HEALTH_DATA_TYPE, + StepCountReader.STEP_SUMMARY_DATA_TYPE_NAME + }); + sleepPermissionSet = createPermissionSet( + new String[]{HealthConstants.Sleep.HEALTH_DATA_TYPE}); + weightPermissionSet = createPermissionSet( + new String[]{HealthConstants.Weight.HEALTH_DATA_TYPE}); + bloodPressurePermissionSet = createPermissionSet( + new String[]{HealthConstants.BloodPressure.HEALTH_DATA_TYPE}); + } + + public static SHealthPermissionSet getInstance() { + if (sHealthPermissionSet == null) { + sHealthPermissionSet = new SHealthPermissionSet(); + } + return sHealthPermissionSet; + } + + public Set getStepPermissionSet() { + return stepPermissionSet; + } + + public Set getSleepPermissionSet() { + return sleepPermissionSet; + } + + public Set getWeightPermissionSet() { + return weightPermissionSet; + } + + public Set getBloodPressurePermissionSet() { + return bloodPressurePermissionSet; + } + + private Set createPermissionSet(String[] dataTypes) { + Set pmsKeySet = new HashSet<>(); + for (String permissionKey : dataTypes) { + pmsKeySet.add(new HealthPermissionManager.PermissionKey( + permissionKey, + HealthPermissionManager.PermissionType.READ) + ); + } + return pmsKeySet; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/SHealthUtils.java b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/SHealthUtils.java new file mode 100644 index 0000000..e1dc7df --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/shealth/utils/SHealthUtils.java @@ -0,0 +1,78 @@ +package nl.sense.rninputkit.inputkit.shealth.utils; + +import android.util.Pair; + +import com.samsung.android.sdk.healthdata.HealthDataResolver; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Locale; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; + +import nl.sense.rninputkit.inputkit.entity.TimeInterval; +import nl.sense.rninputkit.inputkit.shealth.SHealthConstant; + +/** + * Created by xedi on 10/19/17. + */ + +public final class SHealthUtils { + + public static long toMillis(String dTime, HealthDataResolver.AggregateRequest.TimeGroupUnit timeGroupUnit) { + SimpleDateFormat sdf; + String format = SHealthConstant.DATE_FORMAT_MINUTELY; + if (timeGroupUnit.equals(HealthDataResolver.AggregateRequest.TimeGroupUnit.DAILY)) { + format = SHealthConstant.DATE_FORMAT_DAILY; + } else if (timeGroupUnit.equals(HealthDataResolver.AggregateRequest.TimeGroupUnit.HOURLY)) { + format = SHealthConstant.DATE_FORMAT_HOURLY; + } else if (timeGroupUnit.equals(HealthDataResolver.AggregateRequest.TimeGroupUnit.MINUTELY)) { + format = SHealthConstant.DATE_FORMAT_MINUTELY; + } + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + sdf = new SimpleDateFormat(format, Locale.getDefault()); + try { + cal.setTime(sdf.parse(dTime)); + } catch (ParseException e) { + e.printStackTrace(); + } + return cal.getTimeInMillis(); + } + + public static long timeDiff() { + Calendar cal = Calendar.getInstance(); + return cal.get(Calendar.ZONE_OFFSET); + } + + public static long intervalToMillis(Pair interval) { + HealthDataResolver.AggregateRequest.TimeGroupUnit unit = interval.first; + Integer value = interval.second; + if (unit.equals(HealthDataResolver.AggregateRequest.TimeGroupUnit.DAILY)) { + return SHealthConstant.ONE_DAY * value; + } else if (unit.equals(HealthDataResolver.AggregateRequest.TimeGroupUnit.HOURLY)) { + return SHealthConstant.ONE_HOUR * value; + } else if (unit.equals(HealthDataResolver.AggregateRequest.TimeGroupUnit.MINUTELY)) { + return SHealthConstant.ONE_MINUTE * value; + } + return SHealthConstant.ONE_MINUTE * value; + } + + + public static Pair convertTimeInterval(TimeInterval timeInterval) { + if (timeInterval.getTimeUnit().equals(TimeUnit.DAYS)) { + return new Pair<>(HealthDataResolver.AggregateRequest.TimeGroupUnit.DAILY, + timeInterval.getValue()); + } else if (timeInterval.getTimeUnit().equals(TimeUnit.HOURS)) { + return new Pair<>(HealthDataResolver.AggregateRequest.TimeGroupUnit.HOURLY, + timeInterval.getValue()); + } + if (timeInterval.getTimeUnit().equals(TimeUnit.MINUTES)) { + return new Pair<>(HealthDataResolver.AggregateRequest.TimeGroupUnit.MINUTELY, + timeInterval.getValue()); + } + return new Pair<>(HealthDataResolver.AggregateRequest.TimeGroupUnit.MINUTELY, + timeInterval.getValue()); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/status/IKProviderInfo.java b/android/src/main/java/nl/sense/rninputkit/inputkit/status/IKProviderInfo.java new file mode 100644 index 0000000..b515027 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/status/IKProviderInfo.java @@ -0,0 +1,23 @@ +package nl.sense.rninputkit.inputkit.status; + +import android.text.TextUtils; + +/** + * Created by panjiyudasetya on 10/12/17. + */ + +public class IKProviderInfo extends IKResultInfo { + + public IKProviderInfo(int resultCode, String message) { + super(resultCode); + this.message = TextUtils.isEmpty(message) + ? defaultMessage + : message; + } + + @Override + public String getMessage() { + return this.message; + } + +} diff --git a/android/src/main/java/nl/sense/rninputkit/inputkit/status/IKResultInfo.java b/android/src/main/java/nl/sense/rninputkit/inputkit/status/IKResultInfo.java new file mode 100644 index 0000000..6197101 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/inputkit/status/IKResultInfo.java @@ -0,0 +1,42 @@ +package nl.sense.rninputkit.inputkit.status; + +import androidx.annotation.NonNull; +import android.text.TextUtils; + +/** + * Created by panjiyudasetya on 10/12/17. + */ + +public class IKResultInfo { + protected final String defaultMessage = "UNKNOWN_RESULT_INFO"; + protected int resultCode = 0; + protected String message; + + public IKResultInfo(int resultCode) { + this.resultCode = resultCode; + this.message = defaultMessage; + } + + public IKResultInfo(int resultCode, @NonNull String message) { + this.resultCode = resultCode; + this.message = TextUtils.isEmpty(message) + ? defaultMessage + : message; + } + + public int getResultCode() { + return resultCode; + } + + public String getMessage() { + return message; + } + + @Override + public String toString() { + return "IKResultInfo{" + + "message='" + message + '\'' + + ", resultCode=" + resultCode + + '}'; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/modules/HealthBridge.java b/android/src/main/java/nl/sense/rninputkit/modules/HealthBridge.java new file mode 100644 index 0000000..873067e --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/modules/HealthBridge.java @@ -0,0 +1,615 @@ +package nl.sense.rninputkit.modules; + + +import android.app.Activity; +import android.content.Intent; +import androidx.annotation.NonNull; +import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; + +import nl.sense.rninputkit.data.Constants; +import nl.sense.rninputkit.data.ProviderName; +import nl.sense.rninputkit.helper.BloodPressureConverter; +import nl.sense.rninputkit.helper.ValueConverter; +import nl.sense.rninputkit.helper.WeightConverter; +import nl.sense.rninputkit.modules.health.HealthPermissionPromise; +import nl.sense.rninputkit.modules.health.event.EventHandler; +import nl.sense.rninputkit.service.activity.detector.ActivityMonitoringService; +import com.facebook.react.bridge.ActivityEventListener; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import nl.sense.rninputkit.inputkit.HealthProvider; // TODO IMPORTS +import nl.sense.rninputkit.inputkit.HealthProvider.ProviderType; +import nl.sense.rninputkit.inputkit.InputKit; +import nl.sense.rninputkit.inputkit.constant.IKStatus; +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.googlefit.GoogleFitHealthProvider; +import nl.sense.rninputkit.inputkit.helper.AppHelper; +import nl.sense.rninputkit.inputkit.status.IKProviderInfo; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +import static nl.sense.rninputkit.data.Constants.JS_SUPPORTED_EVENTS; +import static nl.sense.rninputkit.inputkit.constant.IKStatus.Code.IK_NOT_CONNECTED; + +/** + * Created by panjiyudasetya on 5/30/17. + */ + +public class HealthBridge extends ReactContextBaseJavaModule implements ActivityEventListener, + LifecycleEventListener { + + private static final String HEALTH_FIT_MODULE_NAME = "HealthBridge"; + private static final String TAG = HEALTH_FIT_MODULE_NAME; + private ReactApplicationContext mReactContext; + private InputKit mInputKit; + private List mRequestHealthPromises; + private BloodPressureConverter mBloodPressureConverter; + private WeightConverter mWeightConverter; + private ProviderType mActiveProvider; + + @SuppressWarnings("unused") // Used by React Native + public HealthBridge(ReactApplicationContext reactContext) { + super(reactContext); + mReactContext = reactContext; + mReactContext.addLifecycleEventListener(this); + mReactContext.addActivityEventListener(this); + + mBloodPressureConverter = new BloodPressureConverter(); + mWeightConverter = new WeightConverter(); + + mRequestHealthPromises = new ArrayList<>(); + mActiveProvider = ProviderType.GOOGLE_FIT; + } + + @Override + public String getName() { + return HEALTH_FIT_MODULE_NAME; + } + + @Override + public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { + Log.d(TAG, "onActivityResult: Request Code : " + requestCode); + if (requestCode == GoogleFitHealthProvider.GF_PERMISSION_REQUEST_CODE) { + handlePromises(resultCode == Activity.RESULT_OK); + } + } + + @Override + public void onNewIntent(Intent intent) { + // At this point, we don't need to implement anything here + // since we only wants to maintain activity results from + // ConnectionResult#startResolutionForResult() + } + + /** + * Start monitoring health sensors. + * @param typeString Sensor type should be one of these {@link nl.sense.rninputkit.inputkit.constant.SampleType.SampleName} sensor + * @param promise contains an information of subscribing health sensor. + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native Application + public void startMonitoring(final String typeString, final Promise promise) { + switch (mActiveProvider) { + case GOOGLE_FIT: + case SAMSUNG_HEALTH: + ActivityMonitoringService.subscribe(mReactContext); + promise.resolve(null); + break; + + default: + String notSupportedMsg = "Monitoring " + typeString + " is not supported."; + promise.reject(String.valueOf(IKStatus.Code.INVALID_REQUEST), notSupportedMsg); + break; + } + } + + /** + * Stop monitoring health sensors. + * @param typeString Sensor type should be one of these {@link nl.sense.rninputkit.inputkit.constant.SampleType.SampleName} sensor + * @param promise contains an information of unsubscribing health sensor. + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native Application + public void stopMonitoring(final String typeString, final Promise promise) { + switch (mActiveProvider) { + case GOOGLE_FIT: + case SAMSUNG_HEALTH: + ActivityMonitoringService.unsubscribe(mReactContext); + promise.resolve(null); + break; + + default: + String notSupportedMsg = "Monitoring " + typeString + " is not supported."; + promise.reject(String.valueOf(IKStatus.Code.INVALID_REQUEST), notSupportedMsg); + break; + } + } + + /** + * Check Input Kit availability. + * @param promise contains an information whether successfully connect to Input Kit or not. + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void isAvailable(final Promise promise) { + if (!mInputKit.isAvailable()) { + Log.d(TAG, "isAvailable: Make sure to call request permission before called this function"); + promise.reject(String.valueOf(IK_NOT_CONNECTED), IKStatus.INPUT_KIT_NOT_CONNECTED); + return; + } + + if (mInputKit.isAvailable()) promise.resolve(true); + } + + /** + * Check Health Provider installation. + * @param promise contains an information either health provider installed or not. + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void isProviderInstalled(String providerName, Promise promise) { + // Make sure provider name is not null + if (TextUtils.isEmpty(providerName)) { + promise.reject( + String.valueOf(IKStatus.Code.INVALID_REQUEST), + "Provider name must be provided!" + ); + return; + } + + // TODO : Add another handler for supported health providers + if (providerName.equals(ProviderName.GOOGLE_FIT)) { + if (AppHelper.isGoogleFitInstalled(mReactContext)) { + promise.resolve(true); + } else { + promise.resolve(false); + } + return; + } + + promise.reject( + String.valueOf(IKStatus.Code.INVALID_REQUEST), + providerName + " is not supported in InputKit!" + ); + } + + /** + * Check whether permission has been authorised or not. + * @param permissions permission that needs to be checked + * @param promise resolved whenever permission has been authorised + * rejected if it hasn;t + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void isPermissionsAuthorised(ReadableArray permissions, final Promise promise) { + if (!mInputKit.isPermissionsAuthorised(getConvertedPermission(permissions))) { + Log.d(TAG, "isAvailable: Make sure to call request permission before called this function"); + promise.reject(String.valueOf(IK_NOT_CONNECTED), IKStatus.INPUT_KIT_NOT_CONNECTED); + return; + } + + promise.resolve(true); + } + + /** + * Request all related permission for specific API. + * @param permissions containing an array of api permission.
+ * For example : ["sleep", "stepCount"] + * @param promise contains an information whether permissions successfully granted or not. + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void requestPermissions(ReadableArray permissions, final Promise promise) { + mInputKit.authorize(new InputKit.Callback() { + @Override + public void onAvailable(String... addMessages) { + promise.resolve("CONNECTED_TO_INPUT_KIT"); + } + + @Override + public void onNotAvailable(@NonNull IKResultInfo reason) { + promise.reject(String.valueOf(reason.getResultCode()), reason.getMessage()); + } + + @Override + public void onConnectionRefused(@NonNull IKProviderInfo providerInfo) { + String message = providerInfo.getMessage(); + if (message.equals(IKStatus.INPUT_KIT_REQUIRED_GRANTED_PERMISSIONS)) { + mRequestHealthPromises.add(new HealthPermissionPromise(promise, providerInfo)); + } else { + promise.reject(String.valueOf( + providerInfo.getResultCode()), + message); + } + } + }, getConvertedPermission(permissions)); + } + + /** + * Get total distance of walk on specific time range. + * @param startTime epoch for the start date + * @param endTime epoch for the end date + * @param promise containing number of total distance in meters. + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void getDistance(final Double startTime, + final Double endTime, + final Promise promise) { + Log.d(TAG, "getDistance: " + startTime + ", " + endTime); + mInputKit.getDistance( + startTime.longValue(), + endTime.longValue(), + 0, + new InputKit.Result() { + @Override + public void onNewData(Float data) { + Log.d(TAG, "getDistance#onNewData: " + data); + promise.resolve(Double.valueOf(data)); + } + + @Override + public void onError(@NonNull IKResultInfo error) { + promise.reject(String.valueOf(error.getResultCode()), error.getMessage()); + } + }); + } + + /** + * Get distance samples between start and end date (inclusive + overlapping) with the latest ones first and limit them by the limit count. + * The distance sample values returned are always in meters + * Specify a limit of 0 for unlimited samples + * @param startTime epoch for the start date + * @param endTime epoch for the end date + * @param promise containing number of total distance in meters. + * @param limit distance sample set limit + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void getDistanceSamples(final Double startTime, + final Double endTime, + final Integer limit, + final Promise promise) { + Log.d(TAG, "getDistanceSamples: " + startTime + ", " + endTime + ", " + limit); + mInputKit.getDistanceSamples( + startTime.longValue(), + endTime.longValue(), + limit, + new InputKit.Result>>() { + @Override + public void onNewData(List> data) { + WritableArray objects = ValueConverter.toWritableArray(data); + Log.d(TAG, "getDistanceSample#onNewData: " + objects); + promise.resolve(objects); + } + + @Override + public void onError(@NonNull IKResultInfo error) { + promise.reject(String.valueOf(error.getResultCode()), error.getMessage()); + } + }); + } + + /** + * Get total steps count of specific range + * @param startTime epoch for the start date + * @param endTime epoch for the end date + * @param promise containing number of total steps count + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void getStepCount(final Double startTime, + final Double endTime, + final Promise promise) { + Log.d(TAG, "getStepCount: " + startTime + ", " + endTime); + mInputKit.getStepCount( + startTime.longValue(), + endTime.longValue(), + 0, + new InputKit.Result() { + @Override + public void onNewData(Integer data) { + Log.d(TAG, "getStepCount#onNewData: " + data); + promise.resolve(data); + } + + @Override + public void onError(@NonNull IKResultInfo error) { + promise.reject(String.valueOf(error.getResultCode()), error.getMessage()); + } + }); + } + + /** + * Returns Promise contains 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 promise containing: + * value: array of data points + * startDate: start date + * endDate: end date + **/ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void getStepCountDistribution(final Double startTime, + final Double endTime, + final String interval, + final Promise promise) { + Log.d(TAG, "getStepCountDistribution: " + startTime + ", " + endTime + ", " + interval); + mInputKit.getStepCountDistribution( + startTime.longValue(), + endTime.longValue(), + interval, + 0, + new InputKit.Result() { + @Override + public void onNewData(StepContent data) { + Log.d(TAG, "getStepCountDistribution#onNewData: " + data.toJson()); + WritableMap object = ValueConverter.toWritableMap(data); + Log.d(TAG, "getStepCountDistribution#onNewData: CONVERTED " + object); + promise.resolve(object); + } + + @Override + public void onError(@NonNull IKResultInfo error) { + promise.reject(String.valueOf(error.getResultCode()), error.getMessage()); + } + }); + } + + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void getSleepAnalysisSamples(final Double startTime, + final Double endTime, + final Promise promise) { + mInputKit.getSleepAnalysisSamples( + startTime.longValue(), + endTime.longValue(), + new InputKit.Result>>() { + @Override + public void onNewData(List> data) { + WritableArray object = ValueConverter.toWritableArray(data); + promise.resolve(object); + } + @Override + public void onError(@NonNull IKResultInfo error) { + promise.reject(String.valueOf(error.getResultCode()), error.getMessage()); + } + } + ); + } + + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void getWeightData(final Double startTime, + final Double endTime, + final Promise promise) { + mInputKit.getWeight( + startTime.longValue(), + endTime.longValue(), + new InputKit.Result>() { + @Override + public void onNewData(List data) { + WritableArray object = mWeightConverter.toWritableMap(data); + promise.resolve(object); + } + @Override + public void onError(@NonNull IKResultInfo error) { + promise.reject(String.valueOf(error.getResultCode()), error.getMessage()); + } + } + ); + } + + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void getBloodPressure(final Double startTime, + final Double endTime, + final Promise promise) { + mInputKit.getBloodPressure( + startTime.longValue(), + endTime.longValue(), + new InputKit.Result>() { + @Override + public void onNewData(List data) { + WritableArray object = mBloodPressureConverter.toWritableMap(data); + promise.resolve(object); + } + @Override + public void onError(@NonNull IKResultInfo error) { + promise.reject(String.valueOf(error.getResultCode()), error.getMessage()); + } + } + + ); + } + + /** + * Start tracking specific sensor. + * + * @param sampleType Sample type should be one of these {@link SampleType.SampleName} sensor + * @param startTime Start time of sensor tracking. Actually on Android is not necessary since + * it will use refresh rate + * @param promise Containing an information of request code and code message whether + * tracking action successfully or not + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void startTracking(final String sampleType, + final Double startTime, + final Promise promise) { + mInputKit.startTracking( + sampleType, + Pair.create(1, TimeUnit.MINUTES), + new HealthProvider.SensorListener() { + @Override + public void onSubscribe(@NonNull IKResultInfo info) { + if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) { + promise.resolve(info.getMessage()); + return; + } + promise.reject(String.valueOf(info.getResultCode()), info.getMessage()); + } + + @Override + public void onReceive(@NonNull SensorDataPoint data) { + if (mReactContext != null) { + EventHandler.emit( + mReactContext.getApplicationContext(), + JS_SUPPORTED_EVENTS.get(Constants.EVENTS.inputKitTracking), + data, + // TODO : Does completion callback is necessary? + new Callback() { + @Override + public void invoke(Object... args) { + + } + } + ); + } + } + + @Override + public void onUnsubscribe(@NonNull IKResultInfo info) { + promise.reject(String.valueOf(info.getResultCode()), info.getMessage()); + } + }); + } + + /** + * Stop tracking specific sensor. + * + * @param sampleType Sample type should be one of these {@link SampleType.SampleName} sensor + * @param promise Containing an information of request code and code message whether + * tracking action successfully or not + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void stopTracking(final String sampleType, + final Promise promise) { + mInputKit.stopTracking( + sampleType, + new HealthProvider.SensorListener() { + @Override + public void onSubscribe(@NonNull IKResultInfo info) { } + + @Override + public void onReceive(@NonNull SensorDataPoint data) { } + + @Override + public void onUnsubscribe(@NonNull IKResultInfo info) { + if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) { + promise.resolve(info.getMessage()); + return; + } + promise.reject(String.valueOf(info.getResultCode()), info.getMessage()); + } + }); + } + + /** + * Stop all tracking sensors. + * + * @param promise Containing an information of request code and code message whether + * tracking action successfully or not + */ + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void stopTrackingAll(final Promise promise) { + mInputKit.stopTrackingAll(new HealthProvider.SensorListener() { + @Override + public void onSubscribe(@NonNull IKResultInfo info) { } + + @Override + public void onReceive(@NonNull SensorDataPoint data) { } + + @Override + public void onUnsubscribe(@NonNull IKResultInfo info) { + if (info.getResultCode() == IKStatus.Code.VALID_REQUEST) { + promise.resolve(info.getMessage()); + return; + } + promise.reject(String.valueOf(info.getResultCode()), info.getMessage()); + } + }); + } + + @Override + public void onHostResume() { + // Do nothing here, as long as host module didn't destroyed, + // we still able to obtain sensor manager & subscribe listener + if (mInputKit == null) { + mInputKit = InputKit.getInstance(mReactContext); + mInputKit.setHealthProvider(mActiveProvider); + } + mInputKit.setHostActivity(getCurrentActivity()); + } + + @Override + public void onHostPause() { + // Do nothing here, as long as host module didn't destroyed, + // we still able to obtain sensor manager & subscribe listener + } + + @Override + public void onHostDestroy() { + // Do nothing here, as long as host module didn't destroyed, + // we still able to obtain sensor manager & subscribe listener + } + + private void handlePromises(boolean isResolved) { + String message = "CONNECTED_TO_INPUT_KIT"; + // Resolve promises, last in first out + for (int i = mRequestHealthPromises.size(); i > 0; i--) { + HealthPermissionPromise permissionPromise = mRequestHealthPromises.get(i - 1); + if (isResolved) { + permissionPromise + .getPromise() + .resolve(message); + } else { + IKProviderInfo info = permissionPromise.getProviderInfo(); + permissionPromise + .getPromise() + .reject(String.valueOf(info.getResultCode()), info.getMessage()); + } + } + mRequestHealthPromises.clear(); + } + + private String[] getConvertedPermission(ReadableArray permissionTypes) { + if (permissionTypes == null || permissionTypes.size() == 0) { + return new String[0]; + } + + List converted = new ArrayList<>(); + for (Object permissionType : permissionTypes.toArrayList()) { + if (String.valueOf(permissionType).equals(SampleType.STEP_COUNT) + || String.valueOf(permissionType).equals(SampleType.DISTANCE_WALKING_RUNNING) + || String.valueOf(permissionType).equals(SampleType.WEIGHT) + || String.valueOf(permissionType).equals(SampleType.BLOOD_PRESSURE)) { + converted.add(String.valueOf(permissionType)); + } + } + return converted.toArray(new String[]{}); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/modules/LoggerBridge.java b/android/src/main/java/nl/sense/rninputkit/modules/LoggerBridge.java new file mode 100644 index 0000000..0a833e4 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/modules/LoggerBridge.java @@ -0,0 +1,43 @@ +package nl.sense.rninputkit.modules; + + +import android.util.Log; + +import nl.sense.rninputkit.BuildConfig; // TODO IMPORTS +import nl.sense.rninputkit.helper.LoggerFileWriter; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +/** + * Created by panjiyudasetya on 6/21/17. + */ + +public class LoggerBridge extends ReactContextBaseJavaModule { + private static final String LOGGER_MODULE_NAME = "Logger"; + private static final String TAG = LOGGER_MODULE_NAME; + private LoggerFileWriter mLogger; + + @SuppressWarnings("unused") // Used by React Native + public LoggerBridge(ReactApplicationContext reactContext) { + super(reactContext); + if (BuildConfig.IS_DEBUG_MODE_ENABLED) { + mLogger = new LoggerFileWriter(reactContext); + } + } + + @Override + public String getName() { + return LOGGER_MODULE_NAME; + } + + @ReactMethod + @SuppressWarnings("unused")//Used by React Native application + public void log(String message) { + if (BuildConfig.IS_DEBUG_MODE_ENABLED && mLogger != null) { + Log.d(TAG, "[SenseLogger] : " + message); + mLogger.logEvent(System.currentTimeMillis(), TAG, message); + } + } + +} diff --git a/android/src/main/java/nl/sense/rninputkit/modules/health/HealthPermissionPromise.java b/android/src/main/java/nl/sense/rninputkit/modules/health/HealthPermissionPromise.java new file mode 100644 index 0000000..944d8d7 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/modules/health/HealthPermissionPromise.java @@ -0,0 +1,30 @@ +package nl.sense.rninputkit.modules.health; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.Promise; + +import nl.sense.rninputkit.inputkit.status.IKProviderInfo; + +/** + * Created by panjiyudasetya on 11/20/17. + */ + +public class HealthPermissionPromise { + private Promise promise; + private IKProviderInfo providerInfo; + + public HealthPermissionPromise(@NonNull Promise promise, + @NonNull IKProviderInfo providerInfo) { + this.promise = promise; + this.providerInfo = providerInfo; + } + + public Promise getPromise() { + return promise; + } + + public IKProviderInfo getProviderInfo() { + return providerInfo; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/modules/health/event/Event.java b/android/src/main/java/nl/sense/rninputkit/modules/health/event/Event.java new file mode 100644 index 0000000..6f489cf --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/modules/health/event/Event.java @@ -0,0 +1,136 @@ +package nl.sense.rninputkit.modules.health.event; + +import android.os.Bundle; +import androidx.annotation.NonNull; + +import nl.sense.rninputkit.helper.ValueConverter; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.WritableMap; +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import java.util.List; + +import nl.sense.rninputkit.inputkit.entity.IKValue; + +/** + * Created by panjiyudasetya on 7/21/17. + */ + +public class Event { + private static final Gson GSON = new Gson(); + private static final String TOPIC = "topic"; + private static final String SAMPLES = "samples"; + private static final String EVENT_ID = "eventId"; + private static final String EVENT_NAME = "name"; + private String eventId; + private String eventName; + private String topic; + private List> samples; + private Callback completion; + + private Event(@NonNull String eventId, + @NonNull String eventName, + @NonNull String topic, + @NonNull List> samples, + @NonNull Callback completion) { + this.eventId = eventId; + this.eventName = eventName; + this.topic = topic; + this.samples = samples; + this.completion = completion; + } + + public String getEventId() { + return eventId; + } + + public String getEventName() { + return eventName; + } + + public String getTopic() { + return topic; + } + + public List> getSamples() { + return samples; + } + + public Callback getCompletion() { + return completion; + } + + /** + * Convert event into Android {@link Bundle} + * @return {@link Bundle} + */ + public String toJson() { + JsonObject object = new JsonObject(); + object.addProperty(EVENT_ID, eventId); + object.addProperty(EVENT_NAME, eventName); + object.addProperty(TOPIC, topic); + object.addProperty(SAMPLES, GSON.toJson(samples)); + return object.toString(); + } + + /** + * Convert Event payload into writable map + * @return {@link WritableMap} + */ + public WritableMap toWritableMap() { + WritableMap mapValue = Arguments.createMap(); + mapValue.putString(EVENT_ID, eventId); + mapValue.putString(EVENT_NAME, eventName); + mapValue.putString(TOPIC, topic); + + if (samples.isEmpty()) mapValue.putArray(SAMPLES, Arguments.createArray()); + else mapValue.putArray(SAMPLES, ValueConverter.toWritableArray(samples)); + return mapValue; + } + + public static class Builder { + private String newEventId; + private String newEventName; + private String newTopic; + private List> newSamples; + private Callback newCompletion; + + public Builder eventId(@NonNull String newEventId) { + this.newEventId = newEventId; + return this; + } + + public Builder eventName(@NonNull String newEventName) { + this.newEventName = newEventName; + return this; + } + + public Builder topic(@NonNull String newTopic) { + this.newTopic = newTopic; + return this; + } + + + public Builder samples(@NonNull List> newSamples) { + this.newSamples = newSamples; + return this; + } + + public Builder completion(@NonNull Callback newCompletion) { + this.newCompletion = newCompletion; + return this; + } + + public Event build() { + return new Event( + newEventId, + newEventName, + newTopic, + newSamples, + newCompletion + ); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/modules/health/event/EventHandler.java b/android/src/main/java/nl/sense/rninputkit/modules/health/event/EventHandler.java new file mode 100644 index 0000000..97c61fc --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/modules/health/event/EventHandler.java @@ -0,0 +1,201 @@ +package nl.sense.rninputkit.modules.health.event; + +import android.content.Context; +import androidx.annotation.NonNull; +import android.util.Log; + +import nl.sense.rninputkit.modules.LoggerBridge; +import nl.sense.rninputkit.service.EventHandlerTaskService; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.modules.core.DeviceEventManagerModule; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import nl.sense.rninputkit.inputkit.constant.IKStatus; +import nl.sense.rninputkit.inputkit.entity.IKValue; +import nl.sense.rninputkit.inputkit.entity.SensorDataPoint; + +/** + * Created by panjiyudasetya on 7/24/17. + */ + +// TODO: should this class have process queue? +public class EventHandler extends ReactContextBaseJavaModule implements LifecycleEventListener { + private static final String EVENT_HANDLER_MODULE_NAME = "EventHandlerBridge"; + private Set mAvailableListeners = new HashSet<>(); + private List mPendingEvents = new ArrayList<>(); + private Map mCompletionBlocks = new HashMap<>(); + + private LoggerBridge mLogger; + private ReactContext mReactContext; + private boolean mIsHostDestroyed; + + public EventHandler(ReactApplicationContext reactContext) { + super(reactContext); + mReactContext = reactContext; + mReactContext.addLifecycleEventListener(this); + + mLogger = new LoggerBridge(reactContext); + mIsHostDestroyed = false; + } + + @Override + public String getName() { + return EVENT_HANDLER_MODULE_NAME; + } + + /** + * Called by JS layer when a listener is ready + * This method can be called from multiple threads + */ + @ReactMethod + @SuppressWarnings("unused")//used by React Native + public void onListenerReady(String name, Promise promise) { + mLogger.log(String.format("new listener: %s became available.", name)); + + mAvailableListeners.add(name); + for (Event event : mPendingEvents) { + if (name.equals(event.getEventName())) { + emit(event); + } + } + promise.resolve(null); + } + + /** + * Called by JS layer when processing event is completed. + * This method can be called from multiple threads + */ + @ReactMethod + @SuppressWarnings("unused")//used by React Native + public void onEventDidProcessed(String eventId, Promise promise) { + Callback completionHandler = mCompletionBlocks.get(eventId); + if (completionHandler == null) { + // TODO: Notify Error! This should never happen + return; + } + + mCompletionBlocks.remove(eventId); + promise.resolve(null); + // This callback potentially triggers everything to be stopped and de-allocated. + completionHandler.invoke(); + } + + /** + * Called by Headless JS whenever this handler catalyst destroyed. + * @param eventId Event Id + * @param eventName Event name + * @param topic Event topic name + * @param payload Payload event in json format + */ + @ReactMethod + @SuppressWarnings("unused")//used by React Native + public void emit(String eventId, String eventName, String topic, String payload, Promise promise) { + List> payloadObjects; + try { + Type typeToken = new TypeToken>() { }.getType(); + payloadObjects = new Gson().fromJson(payload, typeToken); + } catch (Exception e) { + promise.reject( + String.valueOf(IKStatus.Code.INVALID_REQUEST), + "Payload should be in collection format of IKValue!" + ); + return; + } + + emit(new Event.Builder() + .eventId(eventId) + .eventName(eventName) + .topic(topic) + .samples(payloadObjects) + .completion(new Callback() { + @Override + public void invoke(Object... args) { + // TODO: Add completion handler if needed. + } + }) + .build() + ); + promise.resolve(null); + } + + // Not exposed to JS + // called by internal classes to emit event from sensor listener + public static void emit(@NonNull Context context, + @NonNull String eventName, + @NonNull SensorDataPoint dataPoint, + @NonNull Callback completionBlock) { + EventHandlerTaskService.sendEvent( + context, + new Event.Builder() + .eventId(ShortCodeGenerator.generateEventID()) + .eventName(eventName) + .topic(dataPoint.getTopic()) + .samples(dataPoint.getPayload()) + .completion(completionBlock) + .build() + ); + } + + // Not exposed to JS + // called by native components such as Health Kit. + // This method can be called from multiple threads + private void emit(@NonNull Event event) { + mLogger.log("Emitting Event : " + event.toJson()); + + // TODO: this check might be not sufficient if there are multiple listeners per type of event. + if (!mAvailableListeners.contains(event.getEventName())) { + mPendingEvents.add( + new Event.Builder() + .eventId(ShortCodeGenerator.generateEventID()) + .eventName(event.getEventName()) + .topic(event.getTopic()) + .samples(event.getSamples()) + .completion(event.getCompletion()) + .build() + ); + return; + } + + mCompletionBlocks.put(event.getEventId(), event.getCompletion()); + + if (!mIsHostDestroyed) { + mReactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(event.getEventName(), event.toWritableMap()); + } else EventHandlerTaskService.sendEvent(mReactContext, event); + } + + @Override + public void onHostResume() { + // Do nothing here, as long as host didn't destroyed, we still have an access + // into DeviceEventManagerModule.RCTDeviceEventEmitter + mIsHostDestroyed = false; + } + + @Override + public void onHostPause() { + // Do nothing here, as long as host didn't destroyed, we still have an access + // into DeviceEventManagerModule.RCTDeviceEventEmitter + } + + @Override + public void onHostDestroy() { + Log.d(EVENT_HANDLER_MODULE_NAME, "onHostDestroy: Prepare initialize event handler state"); + mIsHostDestroyed = true; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/modules/health/event/ShortCodeGenerator.java b/android/src/main/java/nl/sense/rninputkit/modules/health/event/ShortCodeGenerator.java new file mode 100644 index 0000000..7682be4 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/modules/health/event/ShortCodeGenerator.java @@ -0,0 +1,30 @@ +package nl.sense.rninputkit.modules.health.event; + +import java.security.SecureRandom; + +/** + * Created by panjiyudasetya on 7/24/17. + */ + +public class ShortCodeGenerator { + private static final String AB = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + private static SecureRandom rnd = new SecureRandom(); + private ShortCodeGenerator() { } + + public static String generateEventID() { + long currentTimeInSeconds = System.currentTimeMillis() / 1000; + return currentTimeInSeconds + ":" + getCode(4); + } + + private static String getCode(int len) { + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) { + sb.append( + AB.charAt( + rnd.nextInt(AB.length()) + ) + ); + } + return sb.toString(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/EventHandlerTaskService.java b/android/src/main/java/nl/sense/rninputkit/service/EventHandlerTaskService.java new file mode 100644 index 0000000..867c90d --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/EventHandlerTaskService.java @@ -0,0 +1,113 @@ +package nl.sense.rninputkit.service; + +import android.app.ActivityManager; +import android.app.Notification; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import nl.sense.rninputkit.R; // TODO IMPORTS +import nl.sense.rninputkit.modules.health.event.Event; +import nl.sense.rninputkit.service.activity.detector.Constants; +import com.facebook.react.HeadlessJsTaskService; // TODO IMPORTS +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.jstasks.HeadlessJsTaskConfig; // TODO IMPORTS +import com.google.gson.Gson; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static android.os.Build.VERSION.SDK_INT; + +/** + * Created by panjiyudasetya on 7/26/17. + */ + +public class EventHandlerTaskService extends HeadlessJsTaskService { + private static final String TASK_NAME = "EventHandlerTaskService"; + + @Override + public void onCreate() { + super.onCreate(); + // Enable notification channel to make activity recognition works on Android O + if (SDK_INT >= Build.VERSION_CODES.O) { + Notification notification = new ServiceNotificationCompat.Builder(this) + .channelId(Constants.INPUT_KIT_CHANNEL_ID) + .channelName(getString(R.string.name_of_syncing_steps_channel_desc)) + .iconId(R.mipmap.ic_notif) + .content(getString(R.string.title_of_syncing_steps)) + .build(); + startForeground(Constants.STEP_COUNT_SENSOR_CHANNEL_ID, notification); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (SDK_INT >= Build.VERSION_CODES.O) stopForeground(true); + stopSelf(); + } + + public static void sendEvent(@NonNull final Context context, @NonNull Event event) { + // To see detected event, you can uncomment this to show it on Android notification + NotificationHelper.createNotification( + context, + "New " + event.getTopic() + " detected.", + new Gson().toJson(event.getSamples()), + event.getEventId().hashCode() + ); + + // Since we are not sure executed tasks on JS will slowing down the UI or not, + // it's better for us to prevent any actions while app is in foreground + // https://facebook.github.io/react-native/docs/headless-js-android#caveats + if (isAppOnForeground(context)) { + NotificationHelper.createNotification( + context, + "Unable to send data", + "Discarded this event :\n" + new Gson().toJson(event) + "\nto avoid slow UI", + event.getEventId().hashCode()); + return; + } + + Intent intentService = new Intent(context, EventHandlerTaskService.class); + intentService.putExtra("data_event", event.toJson()); + ContextCompat.startForegroundService(context, intentService); + } + + @Override + protected HeadlessJsTaskConfig getTaskConfig(Intent intent) { + Bundle extras = intent == null ? null : intent.getExtras(); + WritableMap data = extras == null ? null : Arguments.fromBundle(extras); + return new HeadlessJsTaskConfig( + TASK_NAME, + data, + TimeUnit.MINUTES.toMillis(5), + true); + } + + private static boolean isAppOnForeground(@NonNull Context context) { + /** + We need to check if app is in foreground otherwise the app will crash. + http://stackoverflow.com/questions/8489993/check-android-application-is-in-foreground-or-not + **/ + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) return false; + + List appProcesses = activityManager.getRunningAppProcesses(); + if (appProcesses == null) return false; + + final String packageName = context.getPackageName(); + for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) { + if (appProcess.importance + == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + && appProcess.processName.equals(packageName)) { + return true; + } + } + return false; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/NotificationHelper.java b/android/src/main/java/nl/sense/rninputkit/service/NotificationHelper.java new file mode 100644 index 0000000..534b555 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/NotificationHelper.java @@ -0,0 +1,39 @@ +package nl.sense.rninputkit.service; + + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import nl.sense.rninputkit.BuildConfig; +import nl.sense.rninputkit.R; // TODO IMPORTS + +import nl.sense.rninputkit.helper.LoggerFileWriter; + +public class NotificationHelper { + + @SuppressWarnings("unused")//Will be used when its necessary + public static void createNotification(@NonNull Context context, + @NonNull String title, + @NonNull String content, + int notificationId) { + if (BuildConfig.IS_NOTIFICATION_DEBUG_ENABLED) { + NotificationCompat.Builder notificationBuilder = + new NotificationCompat.Builder(context, "InputKit") + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(content) + .setNumber(0) + .setBadgeIconType(NotificationCompat.BADGE_ICON_NONE); + NotificationManagerCompat.from(context) + .notify(notificationId, notificationBuilder.build()); + } + + if (BuildConfig.IS_DEBUG_MODE_ENABLED) { + new LoggerFileWriter(context).logEvent(System.currentTimeMillis(), + String.format("==== %s ====", title), + content); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/ServiceNotificationCompat.java b/android/src/main/java/nl/sense/rninputkit/service/ServiceNotificationCompat.java new file mode 100644 index 0000000..ce3871e --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/ServiceNotificationCompat.java @@ -0,0 +1,78 @@ +package nl.sense.rninputkit.service; + + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import android.text.TextUtils; + +import static android.os.Build.VERSION.SDK_INT; + +public class ServiceNotificationCompat { + private ServiceNotificationCompat() { } + public static class Builder { + private Context context; + private String channelId; + private String channelName; + private int iconId; + private String title; + private String content; + + public Builder(@NonNull Context context) { + this.context = context; + } + + public Builder channelId(@NonNull String channelId) { + this.channelId = channelId; + return this; + } + + public Builder channelName(@NonNull String channelName) { + this.channelName = channelName; + return this; + } + + public Builder iconId(int iconId) { + this.iconId = iconId; + return this; + } + + public Builder title(@Nullable String title) { + this.title = title; + return this; + } + + public Builder content(@Nullable String content) { + this.content = content; + return this; + } + + public Notification build() { + if (channelId == null) throw new IllegalStateException("Channel ID must be provided."); + if (channelName == null) throw new IllegalStateException("Channel name must be provided."); + + if (SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel(channelId, channelName, + NotificationManager.IMPORTANCE_NONE); + channel.enableLights(false); + channel.enableVibration(false); + channel.setShowBadge(false); + + NotificationManager nm = ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); + if (nm != null) nm.createNotificationChannel(channel); + } + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) + .setNumber(0) + .setSmallIcon(iconId); + if (!TextUtils.isEmpty(title)) builder.setContentTitle(title); + if (!TextUtils.isEmpty(content)) builder.setContentText(content); + return builder.build(); + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityHandler.java b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityHandler.java new file mode 100644 index 0000000..0e5d4b2 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityHandler.java @@ -0,0 +1,122 @@ +package nl.sense.rninputkit.service.activity.detector; + +import android.content.Context; +import android.content.Intent; +import androidx.annotation.NonNull; +import android.util.Pair; + +import nl.sense.rninputkit.data.Constants; +import nl.sense.rninputkit.modules.health.event.EventHandler; +import com.facebook.react.bridge.Callback; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; + +import nl.sense.rninputkit.inputkit.InputKit; // TODO IMPORTS +import nl.sense.rninputkit.inputkit.entity.DateContent; +import nl.sense.rninputkit.inputkit.entity.IKValue; +import nl.sense.rninputkit.inputkit.entity.SensorDataPoint; +import nl.sense.rninputkit.inputkit.status.IKResultInfo; + +import static nl.sense.rninputkit.data.Constants.JS_SUPPORTED_EVENTS; +import static nl.sense.rninputkit.inputkit.constant.SampleType.DISTANCE_WALKING_RUNNING; +import static nl.sense.rninputkit.inputkit.constant.SampleType.STEP_COUNT; // TODO IMPORTS + +public class ActivityHandler { + public static final String ACTIVITY_TYPE = "activityType"; + public static final String STEP_DISTANCE_ACTIVITY = STEP_COUNT + "," + DISTANCE_WALKING_RUNNING; + private Context mContext; + + public ActivityHandler(Context context) { + this.mContext = context; + } + + public void proceedIntent(Intent intent) { + if (intent == null || intent.getExtras() == null) return; + + + boolean isStepCountActive = InputKit.getInstance(mContext) + .isPermissionsAuthorised(new String[]{STEP_COUNT}); + + boolean isDistanceActive = InputKit.getInstance(mContext) + .isPermissionsAuthorised(new String[]{DISTANCE_WALKING_RUNNING}); + + String type = intent.getExtras().getString(ACTIVITY_TYPE, ""); + if (type.equals(STEP_DISTANCE_ACTIVITY)) { + if (isStepCountActive) { + emitStepCount(); + } + if (isDistanceActive) { + emitDistance(); + } + } + } + + private void emitStepCount() { + final List> payloads = new ArrayList<>(); + final Pair interval = createInterval(); + InputKit.getInstance(mContext).getStepCount( + new InputKit.Result() { + @Override + public void onNewData(Integer data) { + payloads.add(new IKValue<>( + data, + new DateContent(interval.first), + new DateContent(interval.second) + )); + emit(new SensorDataPoint(STEP_COUNT, payloads)); + } + + @Override + public void onError(@NonNull IKResultInfo error) { } + }); + } + + private void emitDistance() { + final List> payloads = new ArrayList<>(); + final Pair interval = createInterval(); + InputKit.getInstance(mContext).getDistance( + interval.first, + interval.second, + 0, + new InputKit.Result() { + @Override + public void onNewData(Float data) { + payloads.add(new IKValue<>( + data, + new DateContent(interval.first), + new DateContent(interval.second) + )); + emit(new SensorDataPoint(DISTANCE_WALKING_RUNNING, payloads)); + } + + @Override + public void onError(@NonNull IKResultInfo error) { } + }); + } + + private void emit(final SensorDataPoint dataPoint) { + // Emit sensor data point to JS + EventHandler.emit(mContext, + JS_SUPPORTED_EVENTS.get(Constants.EVENTS.inputKitUpdates), + dataPoint, + new Callback() { + @Override + public void invoke(Object... args) { + + } + } // TODO : Does completion callback is necessary? + ); + } + + private Pair createInterval() { + final Calendar cal = Calendar.getInstance(); + cal.setTimeInMillis(System.currentTimeMillis()); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + return Pair.create(cal.getTimeInMillis(), System.currentTimeMillis()); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityMonitoringService.java b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityMonitoringService.java new file mode 100644 index 0000000..d36dca7 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityMonitoringService.java @@ -0,0 +1,225 @@ +package nl.sense.rninputkit.service.activity.detector; + +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import nl.sense.rninputkit.R; // TODO IMPORTS +import nl.sense.rninputkit.service.NotificationHelper; +import nl.sense.rninputkit.service.ServiceNotificationCompat; +import nl.sense.rninputkit.service.scheduler.SchedulerCompat; +import com.google.android.gms.location.ActivityRecognitionClient; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.OnSuccessListener; + +import java.text.DateFormat; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import nl.sense.rninputkit.inputkit.constant.SampleType.SampleName; // TODO IMPORTS + +import static android.os.Build.VERSION.SDK_INT; + +public class ActivityMonitoringService extends Service { + /** The entry point for interacting with activity recognition. */ + private ActivityRecognitionClient mActivityRecognitionClient; + private boolean mIsActivityUpdateRequested = false; + private ActivityHandler mActivityHandler; + + /** Subscribe activity updates */ + public static void subscribe(@NonNull Context context) { + setRequestUpdateState(context, true); + Intent intentService = new Intent(context, ActivityMonitoringService.class); + intentService.setAction(Constants.SUBSCRIBE_ACTIVITY_UPDATES); + ContextCompat.startForegroundService(context, intentService); + } + + /** Unsubscribe activity updates */ + public static void unsubscribe(@NonNull Context context) { + setRequestUpdateState(context, false); + Intent intentService = new Intent(context, ActivityMonitoringService.class); + intentService.setAction(Constants.UNSUBSCRIBE_ACTIVITY_UPDATES); + ContextCompat.startForegroundService(context, intentService); + } + + /** Restore state of activity updates */ + public static void restoreActivityState(@NonNull Context context) { + Intent intentService = new Intent(context, ActivityMonitoringService.class); + intentService.setAction(Constants.RESTORE_ACTIVITY_UPDATES); + ContextCompat.startForegroundService(context, intentService); + } + + /** Proceed detected activity */ + public static void proceedDetectedActivity(@NonNull Context context, + @NonNull @SampleName String activityType) { + Intent intentService = new Intent(context, ActivityMonitoringService.class); + intentService.putExtra(ActivityHandler.ACTIVITY_TYPE, activityType); + intentService.setAction(Constants.NEW_ACTIVITY_DETECTED); + ContextCompat.startForegroundService(context, intentService); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + SchedulerCompat.getInstance(this).onCreate(); + mActivityRecognitionClient = new ActivityRecognitionClient(this); + mActivityHandler = new ActivityHandler(this); + + // Enable notification channel to make activity recognition works on Android O + if (SDK_INT >= Build.VERSION_CODES.O) { + Notification notification = new ServiceNotificationCompat.Builder(this) + .channelId(Constants.INPUT_KIT_CHANNEL_ID) + .channelName(getString(R.string.name_of_syncing_steps_channel_desc)) + .iconId(R.mipmap.ic_notif) + .content(getString(R.string.title_of_syncing_steps)) + .build(); + startForeground(Constants.STEP_COUNT_SENSOR_CHANNEL_ID, notification); + } + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + if (didAskRequestUpdate(this)) { + SchedulerCompat.getInstance(this).onDestroy(); + SchedulerCompat.getInstance(this).scheduleImmediately(); + } else { + SchedulerCompat.getInstance(this).cancelSchedules(); + stopService(); + } + super.onTaskRemoved(rootIntent); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent.getAction() == null ? "" : intent.getAction(); + if (action.equals(Constants.RESTORE_ACTIVITY_UPDATES)) { + requestActivityUpdates(); + } else if ((action.equals(Constants.SUBSCRIBE_ACTIVITY_UPDATES) && !mIsActivityUpdateRequested)) { + requestActivityUpdates(); + } else if (action.equals(Constants.UNSUBSCRIBE_ACTIVITY_UPDATES)) { + removeActivityUpdates(); + } else if (action.equals(Constants.NEW_ACTIVITY_DETECTED) && didAskRequestUpdate(this)) { + mActivityHandler.proceedIntent(intent); + } else { + stopService(); + } + return START_NOT_STICKY; + } + + /** + * Registers for activity recognition updates using + * {@link ActivityRecognitionClient#requestActivityUpdates(long, PendingIntent)}. + * Registers success and failure callbacks. + */ + public void requestActivityUpdates() { + if (didAskRequestUpdate(this)) { + mActivityRecognitionClient + .requestActivityUpdates(TimeUnit.HOURS.toMillis(2), + getActivityDetectionPendingIntent()) + .addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Void result) { + NotificationHelper.createNotification( + ActivityMonitoringService.this, + Constants.ACTIVITY_REPORT_TITLE, + "Successfully request for an activity update. It happens at " + + DateFormat.getInstance().format(new Date()), + 5); + } + }) + .addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + NotificationHelper.createNotification( + ActivityMonitoringService.this, + Constants.ACTIVITY_REPORT_TITLE, + "Failure to request for an activity update. It happens at " + + DateFormat.getInstance().format(new Date()), + 6); + } + }); + } + } + + /** + * Removes activity recognition updates using + * {@link ActivityRecognitionClient#removeActivityUpdates(PendingIntent)}. Registers success and + * failure callbacks. + */ + public void removeActivityUpdates() { + mActivityRecognitionClient + .removeActivityUpdates(getActivityDetectionPendingIntent()) + .addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Void result) { + NotificationHelper.createNotification( + ActivityMonitoringService.this, + Constants.ACTIVITY_REPORT_TITLE, + "Successfully stopping an activity update. It happens at " + + DateFormat.getInstance().format(new Date()), + 7); + stopService(); + } + }) + .addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + NotificationHelper.createNotification( + ActivityMonitoringService.this, + Constants.ACTIVITY_REPORT_TITLE, + "Failure while stopping an activity update. It happens at " + + DateFormat.getInstance().format(new Date()), + 8); + stopService(); + } + }); + } + + /** + * Gets a PendingIntent to be sent for each activity detection. + */ + private PendingIntent getActivityDetectionPendingIntent() { + Intent intent = new Intent(this, DetectedActivitiesIntentService.class); + + // We use FLAG_UPDATE_CURRENT so that we get the same pending intent back when calling + // requestActivityUpdates() and removeActivityUpdates(). + return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * Retrieves the boolean from SharedPreferences that tracks whether we are requesting activity + * updates. + * @param context current application context + * @return True when did ask request updates. False otherwise. + */ + private static boolean didAskRequestUpdate(@NonNull Context context) { + return ActivityState.getInstance(context).didAskRequestUpdate(); + } + + /** + * Sets the boolean in SharedPreferences that tracks whether activity updates request. + * @param context current application context + * @param requestUpdate True if it's successfully request update, False otherwise. + */ + private static void setRequestUpdateState(@NonNull Context context, boolean requestUpdate) { + ActivityState.getInstance(context).setRequestUpdateState(requestUpdate); + } + + private void stopService() { + if (SDK_INT >= Build.VERSION_CODES.O) stopForeground(true); + stopSelf(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityState.java b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityState.java new file mode 100644 index 0000000..e9ef749 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/ActivityState.java @@ -0,0 +1,45 @@ +package nl.sense.rninputkit.service.activity.detector; + +import android.content.Context; +import android.preference.PreferenceManager; +import androidx.annotation.NonNull; + +import java.lang.ref.WeakReference; + +public class ActivityState { + private WeakReference ctxReference; + private static ActivityState sInstance; + + private ActivityState(Context context) { + ctxReference = new WeakReference<>(context); + } + + public static ActivityState getInstance(@NonNull Context context) { + if (sInstance == null || sInstance.ctxReference == null + || sInstance.ctxReference.get() == null) { + sInstance = new ActivityState(context); + } + return sInstance; + } + + /** + * Retrieves the boolean from SharedPreferences that tracks whether we are requesting activity + * updates. + * @return True when did ask request updates. False otherwise. + */ + public boolean didAskRequestUpdate() { + return PreferenceManager.getDefaultSharedPreferences(ctxReference.get()) + .getBoolean(Constants.KEY_ACTIVITY_UPDATES_REQUESTED, false); + } + + /** + * Sets the boolean in SharedPreferences that tracks whether activity updates request. + * @param requestUpdate True if it's successfully request update, False otherwise. + */ + public void setRequestUpdateState(boolean requestUpdate) { + PreferenceManager.getDefaultSharedPreferences(ctxReference.get()) + .edit() + .putBoolean(Constants.KEY_ACTIVITY_UPDATES_REQUESTED, requestUpdate) + .apply(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/activity/detector/Constants.java b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/Constants.java new file mode 100644 index 0000000..82ee245 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/Constants.java @@ -0,0 +1,13 @@ +package nl.sense.rninputkit.service.activity.detector; + +public class Constants { + public static final String INPUT_KIT_CHANNEL_ID = "input_kit_channel"; + public static final int STEP_COUNT_SENSOR_CHANNEL_ID = 1; + public static final String ACTIVITY_REPORT_TITLE = "Activity Update Report"; + public static final String KEY_ACTIVITY_UPDATES_REQUESTED = "KEY_ACTIVITY_UPDATES_REQUESTED"; + public static final String RESTORE_ACTIVITY_UPDATES = "RESTORE_ACTIVITY_UPDATES"; + public static final String SUBSCRIBE_ACTIVITY_UPDATES = "SUBSCRIBE_ACTIVITY_UPDATES"; + public static final String UNSUBSCRIBE_ACTIVITY_UPDATES = "UNSUBSCRIBE_ACTIVITY_UPDATES"; + public static final String NEW_ACTIVITY_DETECTED = "NEW_ACTIVITY_DETECTED"; + private Constants() { } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/activity/detector/DetectedActivitiesIntentService.java b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/DetectedActivitiesIntentService.java new file mode 100644 index 0000000..07112f1 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/activity/detector/DetectedActivitiesIntentService.java @@ -0,0 +1,100 @@ +/** + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nl.sense.rninputkit.service.activity.detector; + +import android.app.IntentService; +import android.content.Intent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.util.SparseIntArray; + +import com.google.android.gms.location.ActivityRecognitionResult; +import com.google.android.gms.location.DetectedActivity; + +import java.util.ArrayList; + +/** + * IntentService for handling incoming intents that are generated as a result of requesting + * activity updates using + * {@link com.google.android.gms.location.ActivityRecognitionClient#requestActivityUpdates(long, + * android.app.PendingIntent)}. + */ +public class DetectedActivitiesIntentService extends IntentService { + + protected static final String TAG = "DetectedActivitiesIS"; + + /** + * This constructor is required, and calls the super IntentService(String) + * constructor with the name for a worker thread. + */ + public DetectedActivitiesIntentService() { + // Use the TAG to name the worker thread. + super(TAG); + } + + @Override + public void onCreate() { + super.onCreate(); + } + + /** + * Handles incoming intents. + * @param intent The Intent is provided (inside a PendingIntent) when requestActivityUpdates() + * is called. + */ + @SuppressWarnings("unchecked") + @Override + protected void onHandleIntent(Intent intent) { + ActivityRecognitionResult result = ActivityRecognitionResult.extractResult(intent); + // Get the list of the probable activities associated with the current state of the + // device. Each activity is associated with a confidence level, which is an int between + // 0 and 100. + ArrayList detectedActivities = (ArrayList) result.getProbableActivities(); + SparseIntArray detectedActivitiesMap = toMap(detectedActivities); + + boolean isWalkingDetected = doesRequirementMatch(detectedActivitiesMap, + DetectedActivity.WALKING, 50); + boolean isRunningDetected = doesRequirementMatch(detectedActivitiesMap, + DetectedActivity.RUNNING, 50); + + if (isWalkingDetected || isRunningDetected) { + ActivityMonitoringService.proceedDetectedActivity(this, + ActivityHandler.STEP_DISTANCE_ACTIVITY); + } + } + + private SparseIntArray toMap(@Nullable ArrayList detectedActivities) { + if (detectedActivities == null || detectedActivities.size() == 0) { + return new SparseIntArray(0); + } + + SparseIntArray detectedActivitiesMap = new SparseIntArray(detectedActivities.size()); + for (DetectedActivity activity : detectedActivities) { + detectedActivitiesMap.put(activity.getType(), activity.getConfidence()); + } + + return detectedActivitiesMap; + } + + private boolean doesRequirementMatch(@NonNull SparseIntArray detectedActivities, + @NonNull Integer expectedActivity, + int expectedConfidenceValue) { + int actualConfidenceValue = detectedActivities.get(expectedActivity, -1) == -1 + ? 0 : detectedActivities.get(expectedActivity); + return actualConfidenceValue > expectedConfidenceValue; + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/broadcasts/BootReceiver.java b/android/src/main/java/nl/sense/rninputkit/service/broadcasts/BootReceiver.java new file mode 100644 index 0000000..50c0415 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/broadcasts/BootReceiver.java @@ -0,0 +1,24 @@ +package nl.sense.rninputkit.service.broadcasts; + + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import nl.sense.rninputkit.service.activity.detector.ActivityState; +import nl.sense.rninputkit.service.scheduler.SchedulerCompat; + +public class BootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (ActivityState.getInstance(context).didAskRequestUpdate()) { + String action = intent == null + ? "" : intent.getAction() == null + ? "" : intent.getAction(); + if (action.equals(Intent.ACTION_BOOT_COMPLETED) + || action.equals(Intent.ACTION_MY_PACKAGE_REPLACED)) { + SchedulerCompat.getInstance(context).scheduleDaily(); + } + } + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/scheduler/IScheduler.java b/android/src/main/java/nl/sense/rninputkit/service/scheduler/IScheduler.java new file mode 100644 index 0000000..657e119 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/scheduler/IScheduler.java @@ -0,0 +1,9 @@ +package nl.sense.rninputkit.service.scheduler; + +public interface IScheduler { + void onCreate(); + void scheduleDaily(); + void scheduleImmediately(); + void cancelSchedules(); + void onDestroy(); +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/scheduler/JobSchedulerService.java b/android/src/main/java/nl/sense/rninputkit/service/scheduler/JobSchedulerService.java new file mode 100644 index 0000000..b469bcb --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/scheduler/JobSchedulerService.java @@ -0,0 +1,119 @@ +package nl.sense.rninputkit.service.scheduler; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import androidx.annotation.NonNull; +import nl.sense.rninputkit.helper.LoggerFileWriter; +import nl.sense.rninputkit.service.activity.detector.ActivityMonitoringService; + +import java.util.concurrent.TimeUnit; + +import static android.os.Build.VERSION.SDK_INT; + +public class JobSchedulerService extends JobService { + private static final int IMMEDIATELY_JOB_ID = 1; + private static final int DAILY_JOB_ID = 2; + private static final long HALF_DAY_INTERVAL = 12 * 60 * 60 * 1000L; + + private static void logEvent(@NonNull Context context, + @NonNull String message) { + new LoggerFileWriter(context).logEvent(System.currentTimeMillis(), + JobSchedulerService.class.getName(), + message); + } + + private static JobInfo createJobInfo(@NonNull ComponentName componentName, int type) { + JobInfo.Builder builder; + switch (type) { + case DAILY_JOB_ID: + builder = new JobInfo.Builder(DAILY_JOB_ID, componentName) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setRequiresDeviceIdle(false) + .setPeriodic(HALF_DAY_INTERVAL); + break; + case IMMEDIATELY_JOB_ID : + default: + builder = new JobInfo.Builder(IMMEDIATELY_JOB_ID, componentName) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setRequiresDeviceIdle(false) + .setMinimumLatency(1) + .setOverrideDeadline(TimeUnit.MINUTES.toMillis(1)); + break; + } + return builder.build(); + } + + private static void scheduleEvent(@NonNull Context context, + @NonNull JobInfo jobInfo) { + JobScheduler scheduler; + if (SDK_INT >= Build.VERSION_CODES.M) { + scheduler = context.getSystemService(JobScheduler.class); + } else { + scheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + } + + if (scheduler == null) { + logEvent(context, "Job scheduler is not available"); + return; + } + + int resultCode = scheduler.schedule(jobInfo); + if (resultCode == JobScheduler.RESULT_SUCCESS) { + logEvent(context, "Job scheduled!"); + } else { + logEvent(context, "Job is not scheduled!"); + } + } + + private static void cancelAllEvent(@NonNull Context context) { + JobScheduler scheduler; + if (SDK_INT >= Build.VERSION_CODES.M) { + scheduler = context.getSystemService(JobScheduler.class); + } else { + scheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + } + + if (scheduler == null) { + logEvent(context, "Job scheduler is not available"); + return; + } + + scheduler.cancelAll(); + } + + public static void scheduleDaily(@NonNull Context context) { + ComponentName componentName = new ComponentName(context, JobSchedulerService.class); + scheduleEvent(context, createJobInfo(componentName, DAILY_JOB_ID)); + } + + public static void scheduleImmediately(@NonNull Context context) { + ComponentName componentName = new ComponentName(context, JobSchedulerService.class); + scheduleEvent(context, createJobInfo(componentName, IMMEDIATELY_JOB_ID)); + } + + public static void cancelAllSchedule(@NonNull Context context) { + cancelAllEvent(context); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_NOT_STICKY; + } + + @Override + public boolean onStartJob(JobParameters jobParameters) { + ActivityMonitoringService.restoreActivityState(this); + return true; + } + + @Override + public boolean onStopJob(JobParameters jobParameters) { + return false; + } +} \ No newline at end of file diff --git a/android/src/main/java/nl/sense/rninputkit/service/scheduler/SchedulerCompat.java b/android/src/main/java/nl/sense/rninputkit/service/scheduler/SchedulerCompat.java new file mode 100644 index 0000000..3ca246f --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/scheduler/SchedulerCompat.java @@ -0,0 +1,67 @@ +package nl.sense.rninputkit.service.scheduler; + +import android.content.Context; +import android.os.Build; +import androidx.annotation.NonNull; + +import nl.sense.rninputkit.service.scheduler.v14.AlarmCompat; + +import java.lang.ref.WeakReference; + +import static android.os.Build.VERSION.SDK_INT; + +public class SchedulerCompat implements IScheduler { + private WeakReference ctxReference; + private static SchedulerCompat sInstance; + + private SchedulerCompat(@NonNull Context context) { + ctxReference = new WeakReference<>(context.getApplicationContext()); + } + + public static SchedulerCompat getInstance(@NonNull Context context) { + if (sInstance == null || sInstance.ctxReference == null + || sInstance.ctxReference.get() == null) { + sInstance = new SchedulerCompat(context); + } + return sInstance; + } + + @Override + public void scheduleDaily() { + if (SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + JobSchedulerService.scheduleDaily(ctxReference.get()); + return; + } + AlarmCompat.getInstance(ctxReference.get()).scheduleDaily(); + } + + @Override + public void scheduleImmediately() { + if (SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + JobSchedulerService.scheduleImmediately(ctxReference.get()); + return; + } + AlarmCompat.getInstance(ctxReference.get()).scheduleImmediately(); + } + + @Override + public void cancelSchedules() { + if (SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + JobSchedulerService.cancelAllSchedule(ctxReference.get()); + return; + } + AlarmCompat.getInstance(ctxReference.get()).cancelSchedules(); + } + + @Override + public void onCreate() { + if (SDK_INT >= Build.VERSION_CODES.LOLLIPOP) return; + AlarmCompat.getInstance(ctxReference.get()).onCreate(); + } + + @Override + public void onDestroy() { + if (SDK_INT >= Build.VERSION_CODES.LOLLIPOP) return; + AlarmCompat.getInstance(ctxReference.get()).onDestroy(); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/scheduler/v14/AlarmCompat.java b/android/src/main/java/nl/sense/rninputkit/service/scheduler/v14/AlarmCompat.java new file mode 100644 index 0000000..7e738c1 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/scheduler/v14/AlarmCompat.java @@ -0,0 +1,185 @@ +package nl.sense.rninputkit.service.scheduler.v14; + + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; +import androidx.annotation.NonNull; + +import nl.sense.rninputkit.service.scheduler.IScheduler; + +import java.lang.ref.WeakReference; +import java.util.Calendar; +import java.util.concurrent.TimeUnit; + +import static android.app.AlarmManager.RTC_WAKEUP; +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; +import static android.content.Context.ALARM_SERVICE; +import static android.os.Build.VERSION.SDK_INT; +import static java.util.concurrent.TimeUnit.MINUTES; + +public class AlarmCompat implements IScheduler { + private static final String ACTION_SCHEDULE_ALARM_INTENT = "ACTION_SCHEDULE_ALARM_INTENT"; + private static final int PI_SELF_SCHEDULED_ALARM = 1000; + private static final int PI_REPEATING_ALARM = 2000; + private static final AlarmReceiver ALARM_RECEIVER = new AlarmReceiver(); + + private WeakReference ctxReference; + private AlarmManager mAlarmManager; + private boolean didRegisterAlarmReceiver; + private static AlarmCompat sInstance; + + private AlarmCompat(Context context) { + ctxReference = new WeakReference<>(context.getApplicationContext()); + mAlarmManager = (AlarmManager) context.getSystemService(ALARM_SERVICE); + onCreate(); + } + + public static AlarmCompat getInstance(@NonNull Context context) { + if (sInstance == null || sInstance.ctxReference == null + || sInstance.ctxReference.get() == null) { + sInstance = new AlarmCompat(context); + } + return sInstance; + } + + /** + * Register alarm receiver + */ + @Override + public void onCreate() { + if (!didRegisterAlarmReceiver) { + ctxReference.get().registerReceiver(ALARM_RECEIVER, new IntentFilter(ACTION_SCHEDULE_ALARM_INTENT)); + didRegisterAlarmReceiver = true; + } + } + + /** + * Wake up alarm intent immediately. + */ + @Override + public void scheduleImmediately() { + final long fewMinutesFromNow = System.currentTimeMillis() + MINUTES.toMillis(2); + Intent exactIntent = new Intent(ctxReference.get(), AlarmReceiver.class); + setAlarm(fewMinutesFromNow, PI_SELF_SCHEDULED_ALARM, exactIntent); + } + + /** + * Schedule alarm daily + */ + @Override + public void scheduleDaily() { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.HOUR_OF_DAY, 8); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + + final long firstAlarmTimestamp = calendar.getTimeInMillis(); + calendar.set(Calendar.HOUR_OF_DAY, 20); + final long secondAlarmTimestamp = calendar.getTimeInMillis(); + final long oneDayInMillis = TimeUnit.DAYS.toMillis(1); + + Intent alarmIntent = new Intent(ctxReference.get(), AlarmReceiver.class); + // First alarm must be set at 08:00 + repeatAlarm(firstAlarmTimestamp, oneDayInMillis, PI_REPEATING_ALARM, alarmIntent); + // Second alarm must be set at 20:00 + repeatAlarm(secondAlarmTimestamp, oneDayInMillis, PI_REPEATING_ALARM + 1, alarmIntent); + } + + @Override + public void cancelSchedules() { + cancelAlarm(PI_SELF_SCHEDULED_ALARM); + cancelAlarm(PI_REPEATING_ALARM); + cancelAlarm(PI_REPEATING_ALARM + 1); + } + + /** + * Deregister alarm receiver + */ + @Override + public void onDestroy() { + ctxReference.get().unregisterReceiver(ALARM_RECEIVER); + didRegisterAlarmReceiver = false; + } + + /** + * Helper function to fire an alarm at specific time. + * @param triggerAtMillis Start alarm in milliseconds + * @param alarmId Alarm id + * @param exactHandlerIntent Alarm wake up handler intent + */ + @SuppressWarnings("ObsoleteSdkInt") + private void setAlarm(long triggerAtMillis, + int alarmId, + @NonNull Intent exactHandlerIntent) { + PendingIntent pendingIntent = PendingIntent.getBroadcast( + ctxReference.get(), + alarmId, + exactHandlerIntent, + FLAG_UPDATE_CURRENT); + + if (SDK_INT >= Build.VERSION_CODES.M) { + mAlarmManager.setExactAndAllowWhileIdle( + RTC_WAKEUP, + triggerAtMillis, + pendingIntent + ); + } else if (SDK_INT >= Build.VERSION_CODES.KITKAT) { + mAlarmManager.setExact( + RTC_WAKEUP, + triggerAtMillis, + pendingIntent + ); + } else { + mAlarmManager.set( + RTC_WAKEUP, + triggerAtMillis, + pendingIntent + ); + } + } + + /** + * Helper function to create repeating alarm intent + * @param triggerAtMillis Start alarm in milliseconds + * @param intervalInMillis Interval of repeating alarm in milliseconds + * @param alarmId Alarm id + * @param repeatingHandlerIntent Repeating alarm handler intent + */ + private void repeatAlarm(long triggerAtMillis, + long intervalInMillis, + int alarmId, + @NonNull Intent repeatingHandlerIntent) { + PendingIntent pendingIntent = PendingIntent.getBroadcast( + ctxReference.get(), + alarmId, + repeatingHandlerIntent, + FLAG_UPDATE_CURRENT); + + mAlarmManager.setRepeating( + RTC_WAKEUP, + triggerAtMillis, + intervalInMillis, + pendingIntent + ); + } + + /** + * Helper function to cancel repeating alarm intent + * @param alarmId Alarm id + */ + private void cancelAlarm(int alarmId) { + Intent alarmIntent = new Intent(ctxReference.get(), AlarmReceiver.class); + PendingIntent pendingIntent = PendingIntent.getBroadcast( + ctxReference.get(), + alarmId, + alarmIntent, + FLAG_UPDATE_CURRENT); + + mAlarmManager.cancel(pendingIntent); + } +} diff --git a/android/src/main/java/nl/sense/rninputkit/service/scheduler/v14/AlarmReceiver.java b/android/src/main/java/nl/sense/rninputkit/service/scheduler/v14/AlarmReceiver.java new file mode 100644 index 0000000..8ab90d8 --- /dev/null +++ b/android/src/main/java/nl/sense/rninputkit/service/scheduler/v14/AlarmReceiver.java @@ -0,0 +1,16 @@ +package nl.sense.rninputkit.service.scheduler.v14; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import nl.sense.rninputkit.service.EventHandlerTaskService; +import nl.sense.rninputkit.service.activity.detector.ActivityMonitoringService; + +public class AlarmReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + ActivityMonitoringService.restoreActivityState(context); + EventHandlerTaskService.acquireWakeLockNow(context); + } +} diff --git a/android/src/main/res/mipmap-hdpi/ic_launcher.png b/android/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..cde69bc Binary files /dev/null and b/android/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-hdpi/ic_notif.png b/android/src/main/res/mipmap-hdpi/ic_notif.png new file mode 100755 index 0000000..a88b591 Binary files /dev/null and b/android/src/main/res/mipmap-hdpi/ic_notif.png differ diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher.png b/android/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c133a0c Binary files /dev/null and b/android/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-mdpi/ic_notif.png b/android/src/main/res/mipmap-mdpi/ic_notif.png new file mode 100755 index 0000000..6a1819d Binary files /dev/null and b/android/src/main/res/mipmap-mdpi/ic_notif.png differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..bfa42f0 Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_notif.png b/android/src/main/res/mipmap-xhdpi/ic_notif.png new file mode 100755 index 0000000..46a213b Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_notif.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..324e72c Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_notif.png b/android/src/main/res/mipmap-xxhdpi/ic_notif.png new file mode 100755 index 0000000..7bd1364 Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_notif.png differ diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..7298340 --- /dev/null +++ b/android/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + rninputkit + Actively syncing steps are allowed + NiceDay is actively syncing steps. + diff --git a/android/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml new file mode 100644 index 0000000..319eb0c --- /dev/null +++ b/android/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/example/android/.project b/example/android/.project new file mode 100644 index 0000000..3964dd3 --- /dev/null +++ b/example/android/.project @@ -0,0 +1,17 @@ + + + android + Project android created by Buildship. + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.buildship.core.gradleprojectnature + + diff --git a/example/android/.settings/org.eclipse.buildship.core.prefs b/example/android/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..e889521 --- /dev/null +++ b/example/android/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir= +eclipse.preferences.version=1 diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index ed2df06..661d2ba 100755 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -117,7 +117,14 @@ def jscFlavor = 'org.webkit:android-jsc:+' * on project.ext.react, JavaScript will not be compiled to Hermes Bytecode * and the benefits of using Hermes will therefore be sharply reduced. */ -def enableHermes = project.ext.react.get("enableHermes", false); +def enableHermes = project.ext.react.get("enableHermes", false) + +/** + * Load debug keystore properties from example root project. + */ +def keystorePropertiesFile = rootProject.file("keystores/debug.keystore.properties") +def keyProps = new Properties() +keyProps.load(new FileInputStream(keystorePropertiesFile)) android { compileSdkVersion rootProject.ext.compileSdkVersion @@ -144,10 +151,11 @@ android { } signingConfigs { debug { - storeFile file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' + + storeFile file('../keystores/' + keyProps['storeFile']) + storePassword keyProps['storePassword'] + keyAlias keyProps['keyAlias'] + keyPassword keyProps['keyPassword'] } } buildTypes { diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index e12167c..e430398 100755 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,6 @@ android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" - android:allowBackup="false" android:theme="@style/AppTheme">