initial
9
LICENSING.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
## Modules
|
||||||
|
|
||||||
|
* MPAndroidChart Apache License, Version 2.0 Copyright 2016 Philipp Jahoda, https://github.com/PhilJay/MPAndroidChart
|
||||||
|
* AudioDecoder.decodeToMemory() Apache License, Version 2.0 Copyright (C) 2012 The Android Open Source Project, https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
|
||||||
|
* RotaryKnob MIT License Copyright (c) 2014 Olivier Bagot, http://github.com/hobbe
|
||||||
|
* Camera2BasicFragment Apache License, Version 2.0 Copyright 2014 The Android Open Source Project https://github.com/googlesamples/android-Camera2Basic
|
||||||
|
* Volley Apache License 2.0 Copyright 2011 The Android Open Source Project https://github.com/google/volley
|
||||||
|
* Gson Apache License 2.0 Copyright (C) 2008 Google Inc. https://github.com/google/gson
|
||||||
|
* NumberPicker Apache License 2.0 Copyright (C) 2016 ShawnLin013(Shawn Lin) https://github.com/ShawnLin013/NumberPicker
|
||||||
4
README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
## HeartShield client app
|
||||||
|
|
||||||
|
-- David <git@abanbytes.eu>
|
||||||
|
|
||||||
138
app/app.iml
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module external.linked.project.id=":app" external.linked.project.path="$MODULE_DIR$" external.root.project.path="$MODULE_DIR$/.." external.system.id="GRADLE" type="JAVA_MODULE" version="4">
|
||||||
|
<component name="FacetManager">
|
||||||
|
<facet type="android-gradle" name="Android-Gradle">
|
||||||
|
<configuration>
|
||||||
|
<option name="GRADLE_PROJECT_PATH" value=":app" />
|
||||||
|
</configuration>
|
||||||
|
</facet>
|
||||||
|
<facet type="android" name="Android">
|
||||||
|
<configuration>
|
||||||
|
<option name="SELECTED_BUILD_VARIANT" value="debug" />
|
||||||
|
<option name="ASSEMBLE_TASK_NAME" value="assembleDebug" />
|
||||||
|
<option name="COMPILE_JAVA_TASK_NAME" value="compileDebugSources" />
|
||||||
|
<afterSyncTasks>
|
||||||
|
<task>generateDebugSources</task>
|
||||||
|
</afterSyncTasks>
|
||||||
|
<option name="ALLOW_USER_CONFIGURATION" value="false" />
|
||||||
|
<option name="MANIFEST_FILE_RELATIVE_PATH" value="/src/main/AndroidManifest.xml" />
|
||||||
|
<option name="RES_FOLDER_RELATIVE_PATH" value="/src/main/res" />
|
||||||
|
<option name="RES_FOLDERS_RELATIVE_PATH" value="file://$MODULE_DIR$/src/main/res" />
|
||||||
|
<option name="ASSETS_FOLDER_RELATIVE_PATH" value="/src/main/assets" />
|
||||||
|
</configuration>
|
||||||
|
</facet>
|
||||||
|
</component>
|
||||||
|
<component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_1_7" inherit-compiler-output="false">
|
||||||
|
<output url="file://$MODULE_DIR$/build/intermediates/classes/debug" />
|
||||||
|
<output-test url="file://$MODULE_DIR$/build/intermediates/classes/test/debug" />
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/r/debug" isTestSource="false" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/aidl/debug" isTestSource="false" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/debug" isTestSource="false" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/debug" isTestSource="false" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/apt/debug" isTestSource="false" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/debug" type="java-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/res/resValues/debug" type="java-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/r/androidTest/debug" isTestSource="true" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/aidl/androidTest/debug" isTestSource="true" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/androidTest/debug" isTestSource="true" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/androidTest/debug" isTestSource="true" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/apt/androidTest/debug" isTestSource="true" generated="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/androidTest/debug" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/build/generated/res/resValues/androidTest/debug" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/debug/res" type="java-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/debug/resources" type="java-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/debug/assets" type="java-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/debug/aidl" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/debug/java" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/debug/rs" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/debug/shaders" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/testDebug/res" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/testDebug/resources" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/testDebug/assets" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/testDebug/aidl" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/testDebug/java" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/testDebug/rs" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/testDebug/shaders" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/main/res" type="java-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/main/resources" type="java-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/main/assets" type="java-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/main/aidl" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/main/java" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/main/rs" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/main/shaders" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/res" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/resources" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/assets" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/aidl" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/java" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/rs" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/shaders" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/test/res" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/test/resources" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/test/assets" type="java-test-resource" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/test/aidl" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/test/java" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/test/rs" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src/test/shaders" isTestSource="true" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/assets" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/blame" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/builds" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/classes" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/incremental" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/incremental-classes" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/incremental-runtime-classes" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/incremental-safeguard" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/incremental-verifier" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/instant-run-resources" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/instant-run-support" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/jniLibs" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/manifests" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/reload-dex" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/res" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/restart-dex" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/rs" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/shaders" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/split-apk" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/symbols" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/transforms" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/outputs" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="Android API 25 Platform" jdkType="Android SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="runner-0.5" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="espresso-idling-resource-2.2.2" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="gson-2.8.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="constraint-layout-1.0.2" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="hamcrest-library-1.3" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="hamcrest-integration-1.3" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="gridlayout-v7-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-core-ui-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-v13-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-core-utils-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="jsr305-2.0.1" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-fragment-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="espresso-core-2.2.2" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="exposed-instrumentation-api-publish-0.5" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="rules-0.5" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="constraint-layout-solver-1.0.2" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="javax.annotation-api-1.2" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="MPAndroidChart-v3.0.1" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="javax.inject-1" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="number-picker-2.4.3" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-compat-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-v4-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="javawriter-2.1.1" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="volley-1.0.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="hamcrest-core-1.3" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-media-compat-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" scope="TEST" name="junit-4.12" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="appcompat-v7-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="animated-vector-drawable-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-annotations-25.3.0" level="project" />
|
||||||
|
<orderEntry type="library" exported="" name="support-vector-drawable-25.3.0" level="project" />
|
||||||
|
<orderEntry type="module" module-name="signal" exported="" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
75
app/build.gradle
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
def getGitHash = { ->
|
||||||
|
def stdout = new ByteArrayOutputStream()
|
||||||
|
exec {
|
||||||
|
commandLine 'git', 'rev-parse', '--short', 'HEAD'
|
||||||
|
standardOutput = stdout
|
||||||
|
}
|
||||||
|
return stdout.toString().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion 25
|
||||||
|
buildToolsVersion "25.0.2"
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "net.heartshield.neustadt"
|
||||||
|
minSdkVersion 23
|
||||||
|
targetSdkVersion 25
|
||||||
|
|
||||||
|
// For fixes, additional digits may be introduced, e.g. versionName "v0.5.9.1" = versionCode 5910
|
||||||
|
//
|
||||||
|
// Note that each part of a version number is single-digit,
|
||||||
|
// after v0.5.9 comes v0.6.0 so alphanumeric sorting puts them in order.
|
||||||
|
|
||||||
|
versionCode 7313
|
||||||
|
versionName "0.7.3"
|
||||||
|
buildConfigField "String", "CODENAME", "\"neustadt\""
|
||||||
|
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
|
||||||
|
|
||||||
|
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// David: does not help to print Log messages within Android Studio
|
||||||
|
// should be: http://stackoverflow.com/questions/28832144/how-to-turn-on-console-output-in-android-unit-tests
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
// All the usual Gradle options.
|
||||||
|
testLogging {
|
||||||
|
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||||
|
outputs.upToDateWhen {false}
|
||||||
|
showStandardStreams = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
maven { url "https://jitpack.io" } // for MPAndroidChart
|
||||||
|
jcenter() // for ShawnLin013/NumberPicker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compile fileTree(dir: 'libs', include: ['*.jar'])
|
||||||
|
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
|
||||||
|
exclude group: 'com.android.support', module: 'support-annotations'
|
||||||
|
})
|
||||||
|
compile 'com.android.support:appcompat-v7:25.3.0'
|
||||||
|
compile 'com.android.support:support-v13:25.3.0'
|
||||||
|
compile 'com.android.support.constraint:constraint-layout:1.0.2'
|
||||||
|
compile 'com.github.PhilJay:MPAndroidChart:v3.0.1'
|
||||||
|
compile 'com.shawnlin:number-picker:2.4.3'
|
||||||
|
compile 'com.android.volley:volley:1.0.0'
|
||||||
|
compile 'com.google.code.gson:gson:2.8.0'
|
||||||
|
testCompile 'junit:junit:4.12'
|
||||||
|
compile project(path: ':signal')
|
||||||
|
compile 'com.android.support:gridlayout-v7:25.3.0'
|
||||||
|
}
|
||||||
25
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# By default, the flags in this file are appended to flags specified
|
||||||
|
# in /opt/android-sdk/tools/proguard/proguard-android.txt
|
||||||
|
# You can edit the include path and order by changing the proguardFiles
|
||||||
|
# directive in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# Add any project specific keep options here:
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.support.test.InstrumentationRegistry;
|
||||||
|
|
||||||
|
import junit.framework.TestCase;
|
||||||
|
|
||||||
|
public class AppInfoTest extends TestCase {
|
||||||
|
public void testAppInfo() {
|
||||||
|
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||||
|
// note: is it possible to test writing data to SD card?
|
||||||
|
// java.io.FileNotFoundException: /storage/emulated/0/hsh/app_id_info.b (Permission denied)
|
||||||
|
// at net.heartshield.data.AppInfo.persistentAppInfo(AppInfo.java:125)
|
||||||
|
AppInfo appInfo = AppInfo.getInstance(appContext);
|
||||||
|
appInfo.getAppInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void testAppMeta() {
|
||||||
|
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||||
|
// note: is it possible to test writing data to SD card?
|
||||||
|
// java.io.FileNotFoundException: /storage/emulated/0/hsh/app_id_info.b (Permission denied)
|
||||||
|
// at net.heartshield.data.AppInfo.persistentAppInfo(AppInfo.java:125)
|
||||||
|
AppInfo appInfo = AppInfo.getInstance(appContext);
|
||||||
|
appInfo.getAppMeta();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import junit.framework.TestCase;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.support.test.InstrumentationRegistry;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.IVec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-14
|
||||||
|
*/
|
||||||
|
public class MeasurementDataManagerTest extends TestCase {
|
||||||
|
private static final String TAG = "MeasurementDataMgrTest";
|
||||||
|
|
||||||
|
public void testFileCwd() {
|
||||||
|
File f = new File("");
|
||||||
|
System.out.println(f.getAbsolutePath());
|
||||||
|
// /mnt/hsh/protos/dha3
|
||||||
|
// :)
|
||||||
|
}
|
||||||
|
|
||||||
|
//private static final String TEST_STORAGE_DIR = "tmp";
|
||||||
|
|
||||||
|
// TODO: setup routine clearing out and fileUtil.makeDirectories(FileUtil.PERSISTENT_DATA_DIR);
|
||||||
|
|
||||||
|
public void testFileWrite() throws IOException {
|
||||||
|
FileUtil fileUtil = new FileUtil(Environment.getExternalStorageDirectory());
|
||||||
|
fileUtil.makeDirectories(FileUtil.PERSISTENT_DATA_DIR);
|
||||||
|
fileUtil.writeDataFileContents("test.txt", "Hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void testClassifier() throws IOException {
|
||||||
|
final CountDownLatch signal = new CountDownLatch(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Measurement measurement1() throws IOException {
|
||||||
|
final Context appContext = InstrumentationRegistry.getContext();
|
||||||
|
//InputStream is = appContext.getResources().openRawResource(net.heartshield.prevent.test.R.raw.measurement_1492009549_7f07f378);
|
||||||
|
InputStream is = null;
|
||||||
|
////FileInputStream fis = new FileInputStream("app/src/androidTest/res/raw/measurement_1492009549_7F07F378.json");
|
||||||
|
String js = FileUtil.readFileString(is);
|
||||||
|
Measurement m = Measurement.deserialize(js);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MeasurementResult measurement1result() throws IOException {
|
||||||
|
final Context appContext = InstrumentationRegistry.getContext();
|
||||||
|
//InputStream is = appContext.getResources().openRawResource(net.heartshield.prevent.test.R.raw.result_1492009549_7f07f378);
|
||||||
|
InputStream is = null;
|
||||||
|
////FileInputStream fis = new FileInputStream("app/src/test/res/raw/result_1492009549_7F07F378.json");
|
||||||
|
String js = FileUtil.readFileString(is);
|
||||||
|
MeasurementResult r = MeasurementResult.deserialize(js);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doTestClassifier(final CountDownLatch signal, final TestResults results) throws IOException {
|
||||||
|
File externalStorage = new File(Environment.getExternalStorageDirectory(), "hsh-test");
|
||||||
|
FileUtil fileUtil = new FileUtil(externalStorage); // avoid removing the production deployment's files
|
||||||
|
fileUtil.makeDirectories(FileUtil.PERSISTENT_DATA_DIR);
|
||||||
|
fileUtil.removeDataFile(FileUtil.MEASUREMENT_CACHE); // otherwise we keep overwriting the 1st measurement.
|
||||||
|
|
||||||
|
final Measurement measurement = measurement1();
|
||||||
|
final Context appContext = InstrumentationRegistry.getTargetContext();
|
||||||
|
IMeasurementDataManager manager = MeasurementDataManager.getInstance(appContext);
|
||||||
|
|
||||||
|
Log.i(TAG, "manager.create(measurement=" + measurement.getId().toString() + ")");
|
||||||
|
manager.create(measurement, new IMeasurementDataManager.MeasurementResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(Measurement measurement) {
|
||||||
|
Log.i(TAG, "onResult(measurement=" + measurement.getId().toString() + ")");
|
||||||
|
results.measurement = measurement;
|
||||||
|
signal.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
Toast.makeText(appContext, "create() failed", Toast.LENGTH_LONG).show();
|
||||||
|
results.error = new RuntimeException("ErrorCode " + code.toString() + ": " + message);
|
||||||
|
signal.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestResults {
|
||||||
|
Measurement measurement = null;
|
||||||
|
Exception error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void testRemoteClassifier() throws IOException {
|
||||||
|
//Log.i(TAG, "running testRemoteClassifier()...");
|
||||||
|
final CountDownLatch signal = new CountDownLatch(1);
|
||||||
|
|
||||||
|
final TestResults results = new TestResults();
|
||||||
|
|
||||||
|
doTestClassifier(signal, results);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// timeout must be longer than connection timeouts, see MeasurementDataManager.REQUEST_INITIAL_TIMEOUT_MS
|
||||||
|
signal.await(30, TimeUnit.SECONDS); // wait for callback
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
throw new AssertionError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(results.error != null)
|
||||||
|
throw new AssertionError(results.error);
|
||||||
|
|
||||||
|
if(results.measurement == null)
|
||||||
|
throw new AssertionError("no results.error and no results.measurement - doTestClassifier() timed out without MeasurementResultListener callback!");
|
||||||
|
|
||||||
|
//final double expected_pred = 0.105558563134; // ...
|
||||||
|
final MeasurementResult expectedResult = measurement1result();
|
||||||
|
|
||||||
|
assertNotNull(results.measurement.result);
|
||||||
|
|
||||||
|
if(DVec.max(expectedResult.ibis) < 300)
|
||||||
|
throw new IllegalStateException("expect ibis to be in ms");
|
||||||
|
|
||||||
|
// round off to whole ms, to ignore server rounding differences (local testing vs. remote testing)
|
||||||
|
results.measurement.result.ibis = DVec.round(results.measurement.result.ibis);
|
||||||
|
expectedResult.ibis = DVec.round(expectedResult.ibis);
|
||||||
|
|
||||||
|
String res = results.measurement.result.serialize();
|
||||||
|
String expRes = expectedResult.serialize();
|
||||||
|
|
||||||
|
assertEquals(expRes, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.support.test.InstrumentationRegistry;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.android.volley.Request;
|
||||||
|
import com.android.volley.RequestQueue;
|
||||||
|
import com.android.volley.Response;
|
||||||
|
import com.android.volley.VolleyError;
|
||||||
|
import com.android.volley.toolbox.JsonObjectRequest;
|
||||||
|
import com.android.volley.toolbox.Volley;
|
||||||
|
|
||||||
|
import junit.framework.TestCase;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.junit.Before;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
|
||||||
|
public class RemoteClassifierTest extends TestCase {
|
||||||
|
private static final String TAG = "RemoteClassifierTest";
|
||||||
|
|
||||||
|
private static final String VERSION = "0.5.0";
|
||||||
|
private static final String CODENAME = "adha";
|
||||||
|
|
||||||
|
RequestQueue mRequestQueue;
|
||||||
|
List<List<Double>> mTRGBSeries;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUp() throws Exception {
|
||||||
|
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||||
|
mRequestQueue = Volley.newRequestQueue(appContext);
|
||||||
|
|
||||||
|
mTRGBSeries = new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject getAndroidVersions() throws JSONException {
|
||||||
|
JSONObject ver = new JSONObject();
|
||||||
|
ver.put("os.version", System.getProperty("os.version"));
|
||||||
|
ver.put("Build.VERSION.INCREMENTAL", Build.VERSION.INCREMENTAL);
|
||||||
|
ver.put("Build.VERSION.SDK_INT", Build.VERSION.SDK_INT); // API level
|
||||||
|
ver.put("Build.DEVICE", Build.DEVICE);
|
||||||
|
ver.put("Build.MODEL", Build.MODEL);
|
||||||
|
ver.put("Build.PRODUCT", Build.PRODUCT);
|
||||||
|
return ver;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject getAppInfo() throws JSONException {
|
||||||
|
JSONObject appInfo = new JSONObject();
|
||||||
|
appInfo.put("version", VERSION);
|
||||||
|
appInfo.put("codename", CODENAME);
|
||||||
|
appInfo.put("install_android_versions", getAndroidVersions());
|
||||||
|
|
||||||
|
// TODO(david) strong persistence (kept between reinstalls) below
|
||||||
|
appInfo.put("id", "01234567"); // TODO random
|
||||||
|
// 0123456789ABCDEF
|
||||||
|
appInfo.put("first_install_date", System.currentTimeMillis() / 1e3 - 80.0);
|
||||||
|
|
||||||
|
// TODO(david) persistence below
|
||||||
|
appInfo.put("install_date", System.currentTimeMillis() / 1e3 - 80.0);
|
||||||
|
|
||||||
|
return appInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject getRequestData() throws JSONException {
|
||||||
|
JSONObject requestData = new JSONObject();
|
||||||
|
|
||||||
|
//double[] ts = mPlotFilter.getEvenTimes();
|
||||||
|
//double meanFps = ((double) ts.length - 1) / (ts[ts.length-1] - ts[0]);
|
||||||
|
|
||||||
|
double FPS = 30.0;
|
||||||
|
double meanFps = FPS; // TODO(david)
|
||||||
|
|
||||||
|
////
|
||||||
|
|
||||||
|
double mStartTime = ((double) System.currentTimeMillis()) / 1000;
|
||||||
|
|
||||||
|
double dt = 1.0 / FPS;
|
||||||
|
double duration = 75.0;
|
||||||
|
double f = 1.0;
|
||||||
|
double end = mStartTime + duration;
|
||||||
|
|
||||||
|
mTRGBSeries.clear();
|
||||||
|
for(double t = mStartTime; t < end; t += dt) {
|
||||||
|
double val = Math.sin(2*Math.PI*f*t);
|
||||||
|
|
||||||
|
// put sample
|
||||||
|
List<Double> trgbEntry = new ArrayList<>();
|
||||||
|
trgbEntry.add(t);
|
||||||
|
double[] rgb = new double[]{200.0 + val, 0.0, 0.0};
|
||||||
|
for (int i = 0; i < rgb.length; i++)
|
||||||
|
trgbEntry.add(rgb[i]);
|
||||||
|
mTRGBSeries.add(trgbEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
////
|
||||||
|
|
||||||
|
JSONObject metaData = new JSONObject();
|
||||||
|
metaData.put("start_time", mStartTime);
|
||||||
|
metaData.put("ppg_mean_fps", meanFps);
|
||||||
|
metaData.put("app_info", getAppInfo());
|
||||||
|
// TODO(david): camera_param_summary
|
||||||
|
|
||||||
|
JSONObject seriesData = new JSONObject();
|
||||||
|
|
||||||
|
JSONArray ppgData = new JSONArray(mTRGBSeries);
|
||||||
|
seriesData.put("ppg_data", ppgData);
|
||||||
|
|
||||||
|
//requestData.put("meta_data", metaData); // TODO(david)
|
||||||
|
|
||||||
|
JSONObject meta2 = new JSONObject(DemoData.meta_data);
|
||||||
|
meta2.put("start_time", mStartTime);
|
||||||
|
|
||||||
|
requestData.put("meta_data", metaData);
|
||||||
|
requestData.put("series_data", seriesData);
|
||||||
|
|
||||||
|
return requestData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestResults {
|
||||||
|
Exception error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onMeasurementFinished(final CountDownLatch signal, final TestResults results) {
|
||||||
|
//String url = "http://my-json-feed";
|
||||||
|
//String url = "https://mlapi.heartshield.net/v2/rawrfclassify2";
|
||||||
|
String url = "http://192.168.40.246:8000/v3/measurement";
|
||||||
|
|
||||||
|
|
||||||
|
Log.i(TAG, "onMeasurementFinished() - building request.");
|
||||||
|
|
||||||
|
//final Activity activity = this;
|
||||||
|
final Context appContext = InstrumentationRegistry.getTargetContext();
|
||||||
|
|
||||||
|
JSONObject requestData;
|
||||||
|
try {
|
||||||
|
requestData = getRequestData();
|
||||||
|
} catch(JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "onMeasurementFinished() - sending measurement...");
|
||||||
|
Log.i(TAG, "onMeasurementFinished() requestData=" + requestData.toString());
|
||||||
|
|
||||||
|
// TODO(david): request timeout
|
||||||
|
JsonObjectRequest request = new JsonObjectRequest
|
||||||
|
(Request.Method.POST, url, requestData, new Response.Listener<JSONObject>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(JSONObject response) {
|
||||||
|
String pred;
|
||||||
|
Log.i(TAG, "Response: " + response.toString());
|
||||||
|
try {
|
||||||
|
pred = response.get("pred").toString();
|
||||||
|
} catch(JSONException e) {
|
||||||
|
pred = "JSONException: " + e.toString();
|
||||||
|
results.error = e;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "pred: " + pred);
|
||||||
|
signal.countDown();
|
||||||
|
}
|
||||||
|
}, new Response.ErrorListener() {
|
||||||
|
@Override
|
||||||
|
public void onErrorResponse(VolleyError error) {
|
||||||
|
// TODO(david): handle properly
|
||||||
|
Log.e(TAG, "HTTP request failed", error);
|
||||||
|
Toast.makeText(appContext, "HTTP request failed", Toast.LENGTH_LONG).show();
|
||||||
|
results.error = error;
|
||||||
|
signal.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mRequestQueue.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void testRemoteClassifier() {
|
||||||
|
Log.i(TAG, "running onMeasurementFinished()...");
|
||||||
|
final CountDownLatch signal = new CountDownLatch(1);
|
||||||
|
|
||||||
|
final TestResults results = new TestResults();
|
||||||
|
|
||||||
|
onMeasurementFinished(signal, results);
|
||||||
|
|
||||||
|
try {
|
||||||
|
signal.await(30, TimeUnit.SECONDS); // wait for callback
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(results.error != null)
|
||||||
|
throw new RuntimeException(results.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import android.support.test.runner.AndroidJUnit4;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AudioSensor tests which will execute on an Android device.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class AudioSensorTest {
|
||||||
|
|
||||||
|
class AudioLagTester {
|
||||||
|
boolean gotFailedState = false;
|
||||||
|
Exception exception = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* provoke audio lag, to test audio lag detection
|
||||||
|
*/
|
||||||
|
//@Test(expected=IndexOutOfBoundsException.class)
|
||||||
|
@Test
|
||||||
|
public void testAudioLag() throws Exception {
|
||||||
|
final AudioSensor sensor = new AudioSensor(10.0);
|
||||||
|
final AudioLagTester tester = new AudioLagTester();
|
||||||
|
|
||||||
|
sensor.setListener(new ISensor.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onData(int offset, int length) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onState(ISensor.State newState) {
|
||||||
|
if(newState == ISensor.State.FAILED) {
|
||||||
|
tester.gotFailedState = true;
|
||||||
|
tester.exception = sensor.getError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Test the "audio lag" detection by read()ing full buffer sizes.
|
||||||
|
* Due to scheduling jitter, doing so will quickly result in two read()s spaced too far away in time.
|
||||||
|
*/
|
||||||
|
sensor.setReadToBufSizeRatio(1);
|
||||||
|
|
||||||
|
sensor.start();
|
||||||
|
Thread.sleep(AudioSensor.START_TIME_MS); // wait for audio lag to occur
|
||||||
|
sensor.stop();
|
||||||
|
|
||||||
|
assertTrue("should emit FAILED state", tester.gotFailedState);
|
||||||
|
assertTrue("should return AudioLagException error", tester.exception != null && tester.exception.getCause() instanceof AudioSensor.AudioLagException);
|
||||||
|
}
|
||||||
|
}
|
||||||
169865
app/src/androidTest/res/raw/measurement_1492009549_7f07f378.json
Normal file
1970
app/src/androidTest/res/raw/result_1492009549_7f07f378.json
Normal file
63
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="net.heartshield.prevent" >
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
in other words, never request it - minSdkVersion 23 in build.gradle
|
||||||
|
see also https://developer.android.com/guide/topics/data/data-storage.html#AccessingExtFiles
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.flash" android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/heartshield_logo"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/CustomTheme"
|
||||||
|
android:largeHeap="true"
|
||||||
|
>
|
||||||
|
|
||||||
|
<activity android:name="net.heartshield.prevent.DisclaimerActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity android:name="net.heartshield.prevent.HomeActivity" android:theme="@style/CustomTheme" android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity android:name="net.heartshield.prevent.EnterAgeActivity" android:theme="@style/CustomTheme" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.EnterHeightActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.EnterGenderActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.MeasureActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.BreatheActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.PcgActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.ResultListActivity" android:theme="@style/CustomTheme" android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity android:name="net.heartshield.prevent.ResultActivity" android:theme="@style/CustomTheme" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.EditActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity android:name="net.heartshield.prevent.FailActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.ErrorActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity android:name="net.heartshield.prevent.TimeActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.InfoActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.PlaceActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.RelaxActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity android:name="net.heartshield.prevent.DevSettingsActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.CameraTestActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
<activity android:name="net.heartshield.prevent.LicenseInfoActivity" android:theme="@style/CustomTheme" android:noHistory="true" android:screenOrientation="portrait" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/*
|
||||||
|
* OpenKarotz-Android
|
||||||
|
* http://github.com/hobbe/OpenKarotz-Android
|
||||||
|
*
|
||||||
|
* Copyright (c) 2014 Olivier Bagot (http://github.com/hobbe)
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* http://opensource.org/licenses/MIT
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.github.hobbe.android.openkarotz.widget;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.support.v7.widget.AppCompatImageView;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import net.heartshield.prevent.R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotary knob view.
|
||||||
|
* <p>
|
||||||
|
* Inspired by <a href="from http://go-lambda.blogspot.fr/2012/02/rotary-knob-widget-on-android.html">RotaryKnobView
|
||||||
|
* example</a>.
|
||||||
|
*/
|
||||||
|
public class RotaryKnob extends AppCompatImageView {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the widget.
|
||||||
|
* @param context the context
|
||||||
|
*/
|
||||||
|
public RotaryKnob(Context context) {
|
||||||
|
super(context);
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the widget.
|
||||||
|
* @param context the context
|
||||||
|
* @param attrs attribute set
|
||||||
|
*/
|
||||||
|
public RotaryKnob(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the widget.
|
||||||
|
* @param context the context
|
||||||
|
* @param attrs attribute set
|
||||||
|
* @param defStyle default style
|
||||||
|
*/
|
||||||
|
public RotaryKnob(Context context, AttributeSet attrs, int defStyle) {
|
||||||
|
super(context, attrs, defStyle);
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the knob angle.
|
||||||
|
* @return the knob angle
|
||||||
|
*/
|
||||||
|
public float getAngle() {
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the knob angle.
|
||||||
|
* @param angle the knob angle
|
||||||
|
*/
|
||||||
|
public void setAngle(float angle) {
|
||||||
|
this.angle = angle;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the knob listener.
|
||||||
|
* @param l the listener to set
|
||||||
|
*/
|
||||||
|
public void setKnobListener(RotaryKnobListener l) {
|
||||||
|
listener = l;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas c) {
|
||||||
|
c.rotate(angle, getWidth() / 2, getHeight() / 2);
|
||||||
|
super.onDraw(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float getTheta(float x, float y) {
|
||||||
|
float sx = x - (getWidth() / 2.0f);
|
||||||
|
float sy = y - (getHeight() / 2.0f);
|
||||||
|
|
||||||
|
float length = (float) Math.sqrt(sx * sx + sy * sy);
|
||||||
|
float nx = sx / length;
|
||||||
|
float ny = sy / length;
|
||||||
|
float theta = (float) Math.atan2(ny, nx);
|
||||||
|
|
||||||
|
final float rad2deg = (float) (180.0 / Math.PI);
|
||||||
|
float thetaDeg = theta * rad2deg;
|
||||||
|
|
||||||
|
return (thetaDeg < 0) ? thetaDeg + 360.0f : thetaDeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize.
|
||||||
|
*/
|
||||||
|
private void initialize() {
|
||||||
|
this.setImageResource(R.drawable.jog);
|
||||||
|
|
||||||
|
setOnTouchListener(new OnTouchListener() {
|
||||||
|
@Override
|
||||||
|
public boolean onTouch(View v, MotionEvent event) {
|
||||||
|
float x = event.getX(0);
|
||||||
|
float y = event.getY(0);
|
||||||
|
float theta = getTheta(x, y);
|
||||||
|
|
||||||
|
//Log.i("RotaryKnob", "MotionEvent: " + (event.getAction() & MotionEvent.ACTION_MASK));
|
||||||
|
|
||||||
|
switch (event.getAction() & MotionEvent.ACTION_MASK) {
|
||||||
|
case MotionEvent.ACTION_POINTER_DOWN:
|
||||||
|
theta_old = theta;
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_MOVE:
|
||||||
|
invalidate();
|
||||||
|
float delta_theta = theta - theta_old;
|
||||||
|
theta_old = theta;
|
||||||
|
int direction = (delta_theta > 0) ? 1 : -1;
|
||||||
|
angle += 3 * direction;
|
||||||
|
notifyChangeListener(direction, angle);
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
invalidate();
|
||||||
|
delta_theta = theta - theta_old;
|
||||||
|
theta_old = theta;
|
||||||
|
direction = (delta_theta > 0) ? 1 : -1;
|
||||||
|
angle += 3 * direction;
|
||||||
|
notifyReleaseListener(direction, angle);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyChangeListener(int direction, float angl) {
|
||||||
|
if (null != listener) {
|
||||||
|
int arg = Math.round(angl);
|
||||||
|
listener.onKnobChanged(direction, arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyReleaseListener(int direction, float angl) {
|
||||||
|
if (null != listener) {
|
||||||
|
int arg = Math.round(angl);
|
||||||
|
listener.onKnobReleased(direction, arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THe interface describes the knob listener.
|
||||||
|
*/
|
||||||
|
public interface RotaryKnobListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is called on knob change, every time the knob turns.
|
||||||
|
* @param direction the direction of the rotation: {@code 1} for clockwise, else {@code -1}
|
||||||
|
* @param angle the angle of the knob, starting from top position
|
||||||
|
*/
|
||||||
|
public void onKnobChanged(int direction, int angle);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is called on knob selection, each time the user releases it.
|
||||||
|
* @param direction the direction of the rotation: {@code 1} for clockwise, else {@code -1}
|
||||||
|
* @param angle the angle of the knob, starting from top position
|
||||||
|
*/
|
||||||
|
public void onKnobReleased(int direction, int angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private float angle = 0f;
|
||||||
|
|
||||||
|
private float theta_old = 0f;
|
||||||
|
|
||||||
|
private RotaryKnobListener listener;
|
||||||
|
}
|
||||||
34
app/src/main/java/net/heartshield/control/Device.java
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package net.heartshield.control;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies AppInfo with appId and other device-related meta-data.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-07
|
||||||
|
*/
|
||||||
|
public class Device implements IDevice {
|
||||||
|
private AppInfo mAppInfo;
|
||||||
|
|
||||||
|
public Device(Context context) {
|
||||||
|
mAppInfo = AppInfo.getInstance(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getAppId() {
|
||||||
|
JSONObject appInfo = mAppInfo.getAppInfo();
|
||||||
|
String appId;
|
||||||
|
try {
|
||||||
|
appId = appInfo.getString("id");
|
||||||
|
} catch (JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return appId;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/src/main/java/net/heartshield/control/IDevice.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package net.heartshield.control;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies AppInfo with appId and other device-related meta-data.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-07
|
||||||
|
*/
|
||||||
|
public interface IDevice {
|
||||||
|
String getAppId();
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package net.heartshield.control;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
import net.heartshield.data.Measurement;
|
||||||
|
import net.heartshield.sensors.IntensityDetector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates sensors and returns measurement data.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-07
|
||||||
|
*/
|
||||||
|
public interface IMeasurementController {
|
||||||
|
enum State {
|
||||||
|
/** inactive */
|
||||||
|
STOPPED,
|
||||||
|
/** waiting for user to place finger on camera */
|
||||||
|
WAITING,
|
||||||
|
/** waiting for camera signal to normalize */
|
||||||
|
//CALIBRATING,
|
||||||
|
/** capturing heart activity */
|
||||||
|
SAMPLING,
|
||||||
|
/** one of the sensors failed during measurement */
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
void start();
|
||||||
|
void stop() throws FramesDroppedException;
|
||||||
|
|
||||||
|
Measurement getMeasurement();
|
||||||
|
|
||||||
|
State getState();
|
||||||
|
|
||||||
|
class FramesDroppedException extends Exception {
|
||||||
|
private int mDroppedCount;
|
||||||
|
|
||||||
|
FramesDroppedException(int droppedCount) {
|
||||||
|
super(droppedCount + " frames dropped.");
|
||||||
|
this.mDroppedCount = droppedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** events will arrive on the UI thread. */
|
||||||
|
interface Listener {
|
||||||
|
void onState(State state);
|
||||||
|
|
||||||
|
void onFrame(double timeSinceStart, double timeSinceLock, double[] rgb, IntensityDetector.ImageSummary summary);
|
||||||
|
}
|
||||||
|
void setListener(Listener listener);
|
||||||
|
|
||||||
|
double getLockTime();
|
||||||
|
void setQualityLockTime(double timeSinceStart);
|
||||||
|
|
||||||
|
enum FailureReason {
|
||||||
|
NONE,
|
||||||
|
CAMERA_FRAMES_DROPPED,
|
||||||
|
CAMERA_FAIL,
|
||||||
|
ACCELERO_FAIL,
|
||||||
|
AUDIO_FAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
/** type/purpose of the measurement */
|
||||||
|
enum MeasurementMode {
|
||||||
|
@SerializedName("risk")
|
||||||
|
RISK,
|
||||||
|
@SerializedName("endothelial_function")
|
||||||
|
ENDOTHELIAL_FUNCTION,
|
||||||
|
@SerializedName("vital_check")
|
||||||
|
VITAL_CHECK,
|
||||||
|
@SerializedName("paced_breathing")
|
||||||
|
PACED_BREATHING,
|
||||||
|
@SerializedName("sweep_breathing")
|
||||||
|
SWEEP_BREATHING
|
||||||
|
}
|
||||||
|
|
||||||
|
FailureReason getFailureReason();
|
||||||
|
void setUnderTest(boolean underTest);
|
||||||
|
|
||||||
|
void setMode(MeasurementMode mode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
package net.heartshield.control;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.TextureView;
|
||||||
|
|
||||||
|
import net.heartshield.prevent.MeasureActivity;
|
||||||
|
import net.heartshield.sensors.DummyAudioSensor;
|
||||||
|
import net.heartshield.sensors.IAudioSensor;
|
||||||
|
import net.heartshield.sensors.ISensor;
|
||||||
|
import net.heartshield.sensors.ISensorFactory;
|
||||||
|
import net.heartshield.sensors.IntensityDetector;
|
||||||
|
import net.heartshield.data.Measurement;
|
||||||
|
import net.heartshield.data.MeasurementSeries;
|
||||||
|
import net.heartshield.sensors.SensorData;
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.IVec;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates sensors and returns measurement data.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-07
|
||||||
|
*/
|
||||||
|
public class MeasurementController implements IMeasurementController {
|
||||||
|
private static final String TAG = "MeasurementController";
|
||||||
|
|
||||||
|
private State mState = State.STOPPED;
|
||||||
|
private Listener mListener;
|
||||||
|
private Handler mHandler;
|
||||||
|
private boolean isUnderTest;
|
||||||
|
|
||||||
|
private IntensityDetector mIntensitySensor;
|
||||||
|
private IAudioSensor mAudioSensor;
|
||||||
|
private ISensor mAccelerationSensor;
|
||||||
|
private Measurement mMeasurement;
|
||||||
|
private long mStartTimeNanos; // start time in nanoTime timebase
|
||||||
|
private long mCameraStartTimeNanos; // first camera frame in nanoTime timebase
|
||||||
|
private long mStartTimeCamera; // start time in camera timebase
|
||||||
|
private IDevice mDevice;
|
||||||
|
|
||||||
|
public MeasurementController(Looper looper, IDevice device, ISensorFactory sensorFactory, TextureView cameraPreview) {
|
||||||
|
mDevice = device;
|
||||||
|
if(looper != null)
|
||||||
|
mHandler = new Handler(looper);
|
||||||
|
isUnderTest = (looper == null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* nice-to: benchmark the CPU and choose a resolution which we can process in the
|
||||||
|
* time available between frames (30 ms)
|
||||||
|
*/
|
||||||
|
mIntensitySensor = sensorFactory.getIntensityDetector(cameraPreview);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The event rate below controls the chunk size processed in the filter pipeline.
|
||||||
|
* nice-to: decouple GUI updates from the chunk size being processed.
|
||||||
|
* Instead, use a timer that fetches the required data from a FIFO buffer.
|
||||||
|
*/
|
||||||
|
mAudioSensor = sensorFactory.getAudioSensor();
|
||||||
|
|
||||||
|
mAccelerationSensor = sensorFactory.getAccelerationSensor();
|
||||||
|
|
||||||
|
mIntensitySensor.setIntensityListener(mInternalIntensityListener);
|
||||||
|
mAudioSensor.setListener(mAudioListener);
|
||||||
|
mAccelerationSensor.setListener(mAccelerationListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnderTest(boolean underTest) { isUnderTest = underTest; }
|
||||||
|
|
||||||
|
private double mQualityLockTime;
|
||||||
|
/** preliminary "lock" time for UI */
|
||||||
|
private long mLockTimeCameraNanos;
|
||||||
|
private int mLockSampleIdx;
|
||||||
|
private List<Double> mLockTimes;
|
||||||
|
private List<Double> mUnlockTimes;
|
||||||
|
|
||||||
|
private IntensityDetector.IntensityListener mInternalIntensityListener = new IntensityDetector.IntensityListener() {
|
||||||
|
private int mI;
|
||||||
|
private List<Integer> mCameraSettled = new ArrayList<>();
|
||||||
|
|
||||||
|
private void clear() {
|
||||||
|
mCameraSettled = new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onState(IntensityDetector.State newState) {
|
||||||
|
if(newState == IntensityDetector.State.LOCKED) {
|
||||||
|
setState(State.SAMPLING);
|
||||||
|
} else if(newState == IntensityDetector.State.FAILED) {
|
||||||
|
logi("IntensityDetector: FAILED");
|
||||||
|
mFailureReason = FailureReason.CAMERA_FAIL;
|
||||||
|
stopSensors();
|
||||||
|
clear();
|
||||||
|
setState(State.FAILED);
|
||||||
|
} else if(newState == IntensityDetector.State.STOPPED) {
|
||||||
|
clear();
|
||||||
|
} else if(newState == IntensityDetector.State.WAITING) {
|
||||||
|
setState(State.WAITING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrame(long timestamp, long cpuTimeNanos, double[] rgb, IntensityDetector.ImageSummary summary) {
|
||||||
|
IntensityDetector.State state = mIntensitySensor.getState();
|
||||||
|
|
||||||
|
if(state == IntensityDetector.State.WAITING) {
|
||||||
|
if(mStartTimeCamera == 0) {
|
||||||
|
// timestamp timebase reference is NOT the same as nanoTime, only coincidental (and NOT on S7 and S7 edge)
|
||||||
|
mStartTimeCamera = timestamp;
|
||||||
|
mLockTimeCameraNanos = timestamp;
|
||||||
|
mLockSampleIdx = 0;
|
||||||
|
mCameraStartTimeNanos = cpuTimeNanos;
|
||||||
|
Log.i(TAG, "timestampSource=" + mIntensitySensor.getFeatures().timestampSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final double timeSinceStart = ((double) (timestamp - mStartTimeCamera)) / 1e9;
|
||||||
|
final double timeSinceLock = ((double) (timestamp - mLockTimeCameraNanos)) / 1e9;
|
||||||
|
|
||||||
|
if(state == IntensityDetector.State.WAITING) {
|
||||||
|
boolean ok = cameraCheck(rgb, summary);
|
||||||
|
mCameraSettled.add(ok ? 1 : 0);
|
||||||
|
//if(ok) {
|
||||||
|
final double CAM_SETTLED_SECS = 1.0;
|
||||||
|
int camSettledWin = (int) (CAM_SETTLED_SECS * mIntensitySensor.getFps());
|
||||||
|
if(mCameraSettled.size() > camSettledWin) {
|
||||||
|
boolean recentOk = IVec.all(IVec.toArray(mCameraSettled.subList(mCameraSettled.size() - camSettledWin, mCameraSettled.size())));
|
||||||
|
if(recentOk) {
|
||||||
|
logi("stabilized, locking camera A3 at rgb=["+ lockStats.mr +"," + lockStats.mg + "," + lockStats.mb + "]");
|
||||||
|
mLockTimeCameraNanos = timestamp;
|
||||||
|
mLockSampleIdx = mIntensitySensor.getSamples().get(1).length;
|
||||||
|
mIntensitySensor.lock();
|
||||||
|
// this is vanilla camera data, and not influenced by qualityLockTime
|
||||||
|
mLockTimes.add(timeSinceStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if(state == IntensityDetector.State.LOCKED) {
|
||||||
|
// TODO
|
||||||
|
// TODO these checks do not belong here. Move them, e.g. to MeasureActivity.
|
||||||
|
// TODO
|
||||||
|
|
||||||
|
// TODO: crap counter 2 (increment every time you visit here, and estimate the rewrite effort)
|
||||||
|
|
||||||
|
|
||||||
|
// TODO is this efficient? do we care? (copying around whole buffer every frame)
|
||||||
|
double[] r = mIntensitySensor.getSamples().get(1);
|
||||||
|
// TODO define as 2*fps instead
|
||||||
|
|
||||||
|
// S7 camera locking fix:
|
||||||
|
// when locking camera on S7 edge, we get a large bounce in red amplitude
|
||||||
|
// see https://gitlab.com/HeartShield/prevent-android/issues/99
|
||||||
|
//
|
||||||
|
// work around this behavior by only using the window after the locking has taken effect.
|
||||||
|
//
|
||||||
|
//int rangeStart = Math.min(mLockSampleIdx + 2, r.length); // DVec.get() may use zero-length
|
||||||
|
int rangeStart = Math.min(mLockSampleIdx + 14, r.length); // DVec.get() may use zero-length
|
||||||
|
|
||||||
|
double[] recent = DVec.get(r, Math.max(r.length - 60, rangeStart), r.length);
|
||||||
|
|
||||||
|
double amplitudeSwing = (recent.length > 0) ? Math.abs(DVec.max(recent) - DVec.min(recent)) : 0.0;
|
||||||
|
|
||||||
|
if(amplitudeSwing > 20.0 &&
|
||||||
|
(
|
||||||
|
mMode.equals(MeasurementMode.RISK) ||
|
||||||
|
(mMode.equals(MeasurementMode.ENDOTHELIAL_FUNCTION) && timeSinceLock < MeasureActivity.FRONT_TIME_ENDOTHELIAL_FUNCTION) ||
|
||||||
|
(mMode.equals(MeasurementMode.VITAL_CHECK) && timeSinceLock < MeasureActivity.FRONT_TIME_VITAL_CHECK)
|
||||||
|
)) {
|
||||||
|
logi("amplitudeSwing = " + amplitudeSwing + " too large, restarting measurement at timeSinceLock=" + timeSinceLock);
|
||||||
|
// TODO actually, reset time to here, but keep running - so server has full stats of how people measure
|
||||||
|
mCameraSettled.add(0);
|
||||||
|
mIntensitySensor.unlock();
|
||||||
|
mUnlockTimes.add(timeSinceStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mIntensitySensor.getDroppedCount() > 5*75) { // TODO FIXME Tom ugly hack suggested
|
||||||
|
mFailureReason = FailureReason.CAMERA_FRAMES_DROPPED;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stop();
|
||||||
|
} catch (FramesDroppedException e) {}
|
||||||
|
|
||||||
|
setState(State.FAILED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check timestamp source from camera, and use SystemClock.elapsedRealtimeNanos()
|
||||||
|
|
||||||
|
final double timeSinceQualityLock = timeSinceStart - mQualityLockTime;
|
||||||
|
//Log.i(TAG, "postOnFrame() timestamp=" + timestamp + " " + " mStartTimeNanos=" + mStartTimeNanos + " mLockTimeCameraNanos=" + mLockTimeCameraNanos + " nanoTime=" + System.nanoTime() + " // mQualityLockTime=" + mQualityLockTime);
|
||||||
|
//Log.i(TAG, "postOnFrame() timeSinceStart=" + timeSinceStart + " timeSinceLock=" + timeSinceLock + " timeSinceQualityLock=" + timeSinceQualityLock);
|
||||||
|
postOnFrame(rgb, summary, timeSinceStart, Math.min(timeSinceQualityLock, timeSinceLock));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check whether camera has settled into sane values (and user's finger is on the camera).
|
||||||
|
*/
|
||||||
|
private boolean cameraCheck(double[] rgb, IntensityDetector.ImageSummary summary) {
|
||||||
|
//if(summary == null) // may happen if we are not in State.WAITING
|
||||||
|
// return false;
|
||||||
|
|
||||||
|
double v = DVec.mean(rgb);
|
||||||
|
//double mr = DVec.mean(summary.meanRow[0]), mg = DVec.mean(summary.meanRow[1]), mb = DVec.mean(summary.meanRow[2]);
|
||||||
|
double mr = rgb[0], mg = rgb[1], mb = rgb[2];
|
||||||
|
|
||||||
|
// check if auto-exposure has found a good intensity
|
||||||
|
boolean exposureOk = (30.0 < v) && (v < 230.0);
|
||||||
|
//boolean redExposureOk = (30.0 < mr) && (mr < 230.0);
|
||||||
|
boolean redExposureOk = (30.0 < mr);
|
||||||
|
|
||||||
|
// redExposureOk: S7 edge bottoms out at 241. --> rgb=[241.08345052083334,0.0,0.0116015625]
|
||||||
|
// ideas:
|
||||||
|
// * take the exposure settings in the moment of lock, and pass them explicitly (avoids pipeline delay, both directions)
|
||||||
|
// * send lock command twice (avoids request pipeline delay? [I have no idea why requests should be pipelined, but heck)
|
||||||
|
// * adjust exposure settings after locking, to one step below the one where we locked
|
||||||
|
|
||||||
|
// check for quite uniform image
|
||||||
|
// TODO: make it less strict, not IVec.all() but some threshold // also, Sony XA has trouble with this. I remember trouble even thresholded. find out why?
|
||||||
|
final double STDDEV_RED_THRESHOLD = 40.0;
|
||||||
|
boolean colStddevOk = IVec.sum(DVec.lt(summary.stdCol[0], DVec.mul(DVec.ones(summary.stdCol[0].length), STDDEV_RED_THRESHOLD))) > ((int) (0.9 * summary.stdCol[0].length));
|
||||||
|
boolean rowStddevOk = IVec.sum(DVec.lt(summary.stdRow[0], DVec.mul(DVec.ones(summary.stdRow[0].length), STDDEV_RED_THRESHOLD))) > ((int) (0.9 * summary.stdRow[0].length));
|
||||||
|
//boolean colStddevOk = true;
|
||||||
|
//boolean rowStddevOk = true;
|
||||||
|
|
||||||
|
// check whether red enough
|
||||||
|
boolean fractionRedOk = summary.fractionRed > 0.9;
|
||||||
|
//boolean fractionRedOk = true;
|
||||||
|
|
||||||
|
// check for reddish image
|
||||||
|
boolean reddishOk = (mr/(mg + 0.1) > 2.0) && (mr/(mb + 0.1) > 2.0); // +0.1: avoid division by zero
|
||||||
|
//boolean reddishOk = true;
|
||||||
|
|
||||||
|
boolean ok = exposureOk && redExposureOk && colStddevOk && rowStddevOk && reddishOk && fractionRedOk;
|
||||||
|
|
||||||
|
if(++mI % 30 == 0) {
|
||||||
|
// TODO: log everything
|
||||||
|
logi("ok=" + ok + " <- exposureOk=" + exposureOk + " && redExposureOk=" + redExposureOk + " && colStddevOk=" + colStddevOk + " && rowStddevOk=" + rowStddevOk + " && reddishOk=" + reddishOk + " && fractionRedOk=" + fractionRedOk);
|
||||||
|
// + " fractionRed=" + summary.fractionRed // 'summary' may be null now
|
||||||
|
logi("rgb=["+ mr +"," + mg + "," + mb + "]");
|
||||||
|
}
|
||||||
|
lockStats.mr = mr;
|
||||||
|
lockStats.mg = mg;
|
||||||
|
lockStats.mb = mb;
|
||||||
|
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LockStats {
|
||||||
|
public double mr, mg, mb;
|
||||||
|
}
|
||||||
|
private LockStats lockStats = new LockStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
private ISensor.Listener mAudioListener = new ISensor.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onData(int offset, int length) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onState(ISensor.State newState) {
|
||||||
|
logi("AudioSensor: FAILED");
|
||||||
|
if(newState == ISensor.State.FAILED) {
|
||||||
|
stopSensors();
|
||||||
|
mFailureReason = FailureReason.AUDIO_FAIL;
|
||||||
|
setState(State.FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private ISensor.Listener mAccelerationListener = new ISensor.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onData(int offset, int length) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onState(ISensor.State newState) {
|
||||||
|
logi("AccelerationSensor: newState=" + newState);
|
||||||
|
if(newState == ISensor.State.FAILED) {
|
||||||
|
logi("AccelerationSensor: FAILED");
|
||||||
|
stopSensors();
|
||||||
|
mFailureReason = FailureReason.ACCELERO_FAIL;
|
||||||
|
setState(State.FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
if(mState != State.STOPPED && mState != State.FAILED)
|
||||||
|
throw new IllegalStateException("start() must be called in STOPPED/FAILED state, but was in " + mState);
|
||||||
|
|
||||||
|
mMeasurement = new Measurement(mDevice.getAppId());
|
||||||
|
mStartTimeNanos = System.nanoTime();
|
||||||
|
mStartTimeCamera = 0; // set later
|
||||||
|
mLockTimeCameraNanos = 0;
|
||||||
|
mCameraStartTimeNanos = 0;
|
||||||
|
mQualityLockTime = 0.0;
|
||||||
|
mLockTimes = new ArrayList<>();
|
||||||
|
mUnlockTimes = new ArrayList<>();
|
||||||
|
|
||||||
|
mIntensitySensor.setDebugMode(true);
|
||||||
|
mIntensitySensor.start();
|
||||||
|
mAudioSensor.start();
|
||||||
|
mAccelerationSensor.start();
|
||||||
|
|
||||||
|
setState(State.WAITING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() throws FramesDroppedException {
|
||||||
|
if(mState == State.STOPPED) {
|
||||||
|
logi("already stopped, ignoring spurious call");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSensors();
|
||||||
|
updateMeasurementSeries();
|
||||||
|
|
||||||
|
if(mIntensitySensor.getDroppedCount() > 5*75) { // TODO FIXME Tom ugly hack suggested
|
||||||
|
mState = State.FAILED; // silent state change: not emitted
|
||||||
|
throw new FramesDroppedException(mIntensitySensor.getDroppedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(State.STOPPED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopSensors() {
|
||||||
|
Log.i(TAG, "stopSensors()");
|
||||||
|
mIntensitySensor.stop();
|
||||||
|
mAudioSensor.stop();
|
||||||
|
mAccelerationSensor.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getLockTime() {
|
||||||
|
final double lockTime1 = ((double) (mLockTimeCameraNanos - mStartTimeCamera)) / 1e9;
|
||||||
|
return lockTime1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateMeasurementSeries() {
|
||||||
|
|
||||||
|
// both of these are currently since the start of camera track
|
||||||
|
final double lockTime1 = ((double) (mLockTimeCameraNanos - mStartTimeCamera)) / 1e9;
|
||||||
|
final double lockTime2 = mQualityLockTime; // qualityLockTime comes from timeSinceStart.
|
||||||
|
|
||||||
|
final double audioStartTime = (mAudioSensor.getStartTimeNanos() - mStartTimeNanos) / 1e9;
|
||||||
|
|
||||||
|
// Measurement.startTime coincides with mStartTimeNanos, that's the most accurate we can do.
|
||||||
|
mMeasurement.meta.lockTime = Math.max(lockTime1, lockTime2);
|
||||||
|
mMeasurement.meta.lockTimes = DVec.toArray(mLockTimes);
|
||||||
|
mMeasurement.meta.unlockTimes = DVec.toArray(mUnlockTimes);
|
||||||
|
|
||||||
|
// TODO: read and save ISO exposure level.
|
||||||
|
mMeasurement.meta.cameraFeatures = mIntensitySensor.getFeatures();
|
||||||
|
JSONObject cameraMeta = mIntensitySensor.getCameraMeta();
|
||||||
|
try {
|
||||||
|
// should usually be NONE
|
||||||
|
cameraMeta.put("failureReason", mFailureReason.toString());
|
||||||
|
} catch (JSONException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
mMeasurement.meta.cameraMeta = cameraMeta.toString();
|
||||||
|
|
||||||
|
double[] ts = mIntensitySensor.getSamples().get(0);
|
||||||
|
mMeasurement.meta.ppgMeanFps = ((double) (ts.length-1)) / (ts[ts.length-1] - ts[0]);
|
||||||
|
mMeasurement.meta.ppgNumDropped = mIntensitySensor.getDroppedCount();
|
||||||
|
|
||||||
|
mMeasurement.meta.audioStartTime = audioStartTime;
|
||||||
|
mMeasurement.meta.audioStartTimeAccuracy = mAudioSensor.getExpectedStartTimeAccuracy();
|
||||||
|
mMeasurement.meta.audioFps = mAudioSensor.getFps();
|
||||||
|
mMeasurement.meta.bcgFps = mAccelerationSensor.getFps();
|
||||||
|
|
||||||
|
mMeasurement.meta.test = isUnderTest;
|
||||||
|
|
||||||
|
mMeasurement.series = new MeasurementSeries();
|
||||||
|
|
||||||
|
mMeasurement.series.ppgData = SensorData.transposeSamples(mIntensitySensor.getSamples(), (mStartTimeCamera - (mCameraStartTimeNanos - mStartTimeNanos)) / 1e9);
|
||||||
|
mMeasurement.series.bcgData = SensorData.transposeSamples(mAccelerationSensor.getSamples(), mStartTimeNanos / 1e9);
|
||||||
|
mMeasurement.series.setAudio(mAudioSensor.getPCM(), audioStartTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Measurement getMeasurement() { return mMeasurement; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setListener(Listener listener) { mListener = listener; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setQualityLockTime(double timeSinceStart) { mQualityLockTime = timeSinceStart; }
|
||||||
|
|
||||||
|
private void setState(State newState) {
|
||||||
|
logi("setState(" + newState.toString() + ")");
|
||||||
|
State prevState = mState;
|
||||||
|
mState = newState;
|
||||||
|
if(prevState != newState)
|
||||||
|
postOnState(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public State getState() {
|
||||||
|
return mState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postOnFrame(final double[] rgb, final IntensityDetector.ImageSummary summary, final double timeSinceStart, final double timeSinceLock) {
|
||||||
|
if(mListener != null) {
|
||||||
|
final Runnable runnable = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onFrame(timeSinceStart, timeSinceLock, rgb, summary);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if(mHandler != null)
|
||||||
|
mHandler.post(runnable);
|
||||||
|
else
|
||||||
|
runnable.run(); // for unit tests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postOnState(final State state) {
|
||||||
|
if(mListener != null) {
|
||||||
|
final Runnable runnable = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onState(state);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if(mHandler != null)
|
||||||
|
mHandler.post(runnable);
|
||||||
|
else
|
||||||
|
runnable.run(); // for unit tests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logi(String msg) {
|
||||||
|
if(!isUnderTest)
|
||||||
|
Log.i(TAG, msg); // not mocked in unit tests
|
||||||
|
else
|
||||||
|
System.out.println(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FailureReason mFailureReason = FailureReason.NONE;
|
||||||
|
@Override
|
||||||
|
public FailureReason getFailureReason() { return mFailureReason; }
|
||||||
|
|
||||||
|
private MeasurementMode mMode = MeasurementMode.RISK;
|
||||||
|
@Override
|
||||||
|
public void setMode(MeasurementMode mode) {
|
||||||
|
mMode = mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/src/main/java/net/heartshield/control/ReleaseConfig.java
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package net.heartshield.control;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-09-16
|
||||||
|
*/
|
||||||
|
public class ReleaseConfig {
|
||||||
|
/** whether we need audio permissions for recording a parallel ECG */
|
||||||
|
public static final boolean RECORD_ECG = true;
|
||||||
|
|
||||||
|
/** log stats about how long averaging the camera image takes */
|
||||||
|
public static final boolean CAMERA2_DEBUG_PERFORMANCE = true;
|
||||||
|
|
||||||
|
/** whether to compute ImageSummary on frames before locking */
|
||||||
|
public static final boolean CAMERA2_SUMMARIZE = true;
|
||||||
|
|
||||||
|
public static final String[] PERMISSIONS;
|
||||||
|
|
||||||
|
static {
|
||||||
|
// could use the build config for these.
|
||||||
|
//boolean isDebugBuild = BuildConfig.BUILD_TYPE.equals("debug");
|
||||||
|
|
||||||
|
if(RECORD_ECG) {
|
||||||
|
PERMISSIONS = new String[]{
|
||||||
|
Manifest.permission.CAMERA,
|
||||||
|
Manifest.permission.RECORD_AUDIO
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
PERMISSIONS = new String[]{
|
||||||
|
Manifest.permission.CAMERA
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
286
app/src/main/java/net/heartshield/data/AppInfo.java
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Environment;
|
||||||
|
|
||||||
|
import net.heartshield.prevent.BuildConfig;
|
||||||
|
import net.heartshield.util.JsonUtil;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists information about the app, such as its unique ID and install date.
|
||||||
|
* App ID assignments are privacy relevant.
|
||||||
|
*
|
||||||
|
* Needs the WRITE_EXTERNAL_STORAGE permission.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-03-20
|
||||||
|
*/
|
||||||
|
public class AppInfo implements IAppInfo {
|
||||||
|
public static final String VERSION = BuildConfig.VERSION_NAME;
|
||||||
|
public static final int VERSION_CODE = BuildConfig.VERSION_CODE;
|
||||||
|
public static final String CODENAME = BuildConfig.CODENAME;
|
||||||
|
|
||||||
|
private FileUtil mFileUtil;
|
||||||
|
private File mExternalFilesDir;
|
||||||
|
|
||||||
|
private JSONObject mTransientAppInfo;
|
||||||
|
|
||||||
|
private AppInfo(Context context) {
|
||||||
|
//mContext = context;
|
||||||
|
mExternalFilesDir = context.getExternalFilesDir(null);
|
||||||
|
mFileUtil = new FileUtil(mExternalFilesDir);
|
||||||
|
|
||||||
|
|
||||||
|
loadDevSettings(context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
mTransientAppInfo = transientAppInfo(context);
|
||||||
|
} catch (JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppInfo sInstance = null;
|
||||||
|
public static AppInfo getInstance(Context context) {
|
||||||
|
if(sInstance == null) {
|
||||||
|
sInstance = new AppInfo(context);
|
||||||
|
}
|
||||||
|
return sInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DevSettings {
|
||||||
|
/** whether to use the test server instead of production server */
|
||||||
|
public boolean useTestServer = false;
|
||||||
|
|
||||||
|
/** whether to show mock data for demo */
|
||||||
|
//@NoRemote
|
||||||
|
public boolean useMockData = false;
|
||||||
|
|
||||||
|
/** whether to skip all dialogs, including EnterAgeActivity, and just use test data */
|
||||||
|
public boolean skipDialogs = false;
|
||||||
|
|
||||||
|
/** Show personal tracking controls (sleep, happiness) in EditActivity */
|
||||||
|
public boolean showPersonalTracking = false;
|
||||||
|
|
||||||
|
/** Ask for height */
|
||||||
|
public boolean showHeightDialog = false;
|
||||||
|
|
||||||
|
public String serialize(JsonUtil.Target target) {
|
||||||
|
return JsonUtil.getGson(target).toJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DevSettings deserialize(String json) {
|
||||||
|
return JsonUtil.getGson().fromJson(json, DevSettings.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DevSettings devSettings;
|
||||||
|
|
||||||
|
private static final String DEV_SETTINGS = "dev_settings.json";
|
||||||
|
public void saveDevSettings(Context context) {
|
||||||
|
try {
|
||||||
|
mFileUtil.writeDataFileContents(DEV_SETTINGS, devSettings.serialize(JsonUtil.Target.REMOTE));
|
||||||
|
activateDevSettings(context);
|
||||||
|
} catch (IOException e) {
|
||||||
|
// for developers only
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void loadDevSettings(Context context) {
|
||||||
|
try {
|
||||||
|
devSettings = DevSettings.deserialize(mFileUtil.readDataFileString(DEV_SETTINGS));
|
||||||
|
activateDevSettings(context);
|
||||||
|
} catch (IOException e) {
|
||||||
|
// for developers only -- file will never be created for normal users
|
||||||
|
devSettings = new DevSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void activateDevSettings(Context context) {
|
||||||
|
// activate DevSettings
|
||||||
|
IMeasurementDataManager mdm = MeasurementDataManager.getInstance(context);
|
||||||
|
mdm.setUseTestServer(devSettings.useTestServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get descriptive information about the app. Write these files to persistent storage first, if necessary.
|
||||||
|
* Needs the WRITE_EXTERNAL_STORAGE, will throw RuntimeException otherwise.
|
||||||
|
*/
|
||||||
|
public JSONObject getAppInfo() {
|
||||||
|
try {
|
||||||
|
return JsonUtil.mergeJson(mTransientAppInfo, persistentAppInfo());
|
||||||
|
} catch (JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String TRANSIENT_APP_INFO = "transient.b";
|
||||||
|
|
||||||
|
private boolean firstInstall = true;
|
||||||
|
|
||||||
|
public boolean isFirstInstall() { return firstInstall; }
|
||||||
|
|
||||||
|
//public boolean isActivated() { return true; }
|
||||||
|
|
||||||
|
public boolean isActivated() {
|
||||||
|
JSONObject pai = null;
|
||||||
|
try {
|
||||||
|
pai = persistentAppInfo();
|
||||||
|
} catch (JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return pai.has("activation_date");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app info that is deleted between reinstalls or updates.
|
||||||
|
*/
|
||||||
|
private JSONObject transientAppInfo(Context context) throws JSONException {
|
||||||
|
JSONObject tai;
|
||||||
|
|
||||||
|
try {
|
||||||
|
FileInputStream is = context.openFileInput(TRANSIENT_APP_INFO);
|
||||||
|
tai = new JSONObject(FileUtil.readFileString(is));
|
||||||
|
firstInstall = false;
|
||||||
|
} catch (IOException e) {
|
||||||
|
tai = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// not sure if updates will remove the Internal Storage files, so check for VERSION changes
|
||||||
|
if(tai != null && tai.getString("version").equals(VERSION) && tai.getString("codename").equals(CODENAME))
|
||||||
|
return tai;
|
||||||
|
|
||||||
|
firstInstall = true;
|
||||||
|
|
||||||
|
// not existent, or old VERSION, write it now
|
||||||
|
tai = new JSONObject();
|
||||||
|
|
||||||
|
// note: new keys must also be added in {@link AppMeta} to end up on the server - see getAppMeta()
|
||||||
|
tai.put("version", VERSION);
|
||||||
|
tai.put("version_code", VERSION_CODE);
|
||||||
|
tai.put("codename", CODENAME);
|
||||||
|
tai.put("git_hash", BuildConfig.GIT_HASH);
|
||||||
|
tai.put("build_type", BuildConfig.BUILD_TYPE);
|
||||||
|
tai.put("install_date", (long) (System.currentTimeMillis() / 1e3));
|
||||||
|
tai.put("install_android_versions", getAndroidVersions());
|
||||||
|
|
||||||
|
try {
|
||||||
|
FileOutputStream os = context.openFileOutput(TRANSIENT_APP_INFO, Context.MODE_PRIVATE);
|
||||||
|
os.write(tai.toString().getBytes());
|
||||||
|
os.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tai;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app info that is persisted between reinstalls and updates.
|
||||||
|
*/
|
||||||
|
private JSONObject persistentAppInfo() throws JSONException {
|
||||||
|
JSONObject pai;
|
||||||
|
|
||||||
|
if(!isExternalStorageReadable())
|
||||||
|
throw new RuntimeException("cannot read persistent app info (external storage is unreadable)");
|
||||||
|
|
||||||
|
try {
|
||||||
|
File info = new File(mExternalFilesDir.getAbsolutePath() + FileUtil.PERSISTENT_APP_INFO_DIR, FileUtil.PERSISTENT_APP_INFO);
|
||||||
|
pai = new JSONObject(FileUtil.readFileString(new FileInputStream(info)));
|
||||||
|
} catch (IOException e) {
|
||||||
|
pai = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(pai != null)
|
||||||
|
return pai;
|
||||||
|
|
||||||
|
// not existent, write it now
|
||||||
|
|
||||||
|
mFileUtil.makeDirectories(FileUtil.PERSISTENT_APP_INFO_DIR);
|
||||||
|
|
||||||
|
pai = new JSONObject();
|
||||||
|
|
||||||
|
pai.put("id", "");
|
||||||
|
pai.put("first_install_date", (long) (System.currentTimeMillis() / 1e3));
|
||||||
|
|
||||||
|
try {
|
||||||
|
File info = new File(mExternalFilesDir.getAbsolutePath() + FileUtil.PERSISTENT_APP_INFO_DIR, FileUtil.PERSISTENT_APP_INFO);
|
||||||
|
FileOutputStream os = new FileOutputStream(info);
|
||||||
|
os.write(pai.toString().getBytes());
|
||||||
|
os.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pai;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void activate(String activationCode) {
|
||||||
|
// not existent, write it now
|
||||||
|
|
||||||
|
mFileUtil.makeDirectories(FileUtil.PERSISTENT_APP_INFO_DIR);
|
||||||
|
|
||||||
|
JSONObject pai = new JSONObject();
|
||||||
|
|
||||||
|
try {
|
||||||
|
pai.put("id", activationCode.toUpperCase());
|
||||||
|
pai.put("first_install_date", persistentAppInfo().getLong("first_install_date"));
|
||||||
|
pai.put("activation_date", (long) (System.currentTimeMillis() / 1e3));
|
||||||
|
} catch (JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File info = new File(mExternalFilesDir.getAbsolutePath() + FileUtil.PERSISTENT_APP_INFO_DIR, FileUtil.PERSISTENT_APP_INFO);
|
||||||
|
FileOutputStream os = new FileOutputStream(info);
|
||||||
|
os.write(pai.toString().getBytes());
|
||||||
|
os.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checks if external storage is available to at least read */
|
||||||
|
private boolean isExternalStorageReadable() {
|
||||||
|
String state = Environment.getExternalStorageState();
|
||||||
|
if(Environment.MEDIA_MOUNTED.equals(state) ||
|
||||||
|
Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AppMeta getAppMeta() {
|
||||||
|
JSONObject appInfo = getAppInfo();
|
||||||
|
return AppMeta.deserialize(appInfo.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject getAndroidVersions() throws JSONException {
|
||||||
|
JSONObject ver = new JSONObject();
|
||||||
|
ver.put("os.version", System.getProperty("os.version"));
|
||||||
|
ver.put("Build.VERSION.INCREMENTAL", Build.VERSION.INCREMENTAL);
|
||||||
|
ver.put("Build.VERSION.SDK_INT", Build.VERSION.SDK_INT); // API level
|
||||||
|
ver.put("Build.DEVICE", Build.DEVICE);
|
||||||
|
ver.put("Build.MODEL", Build.MODEL);
|
||||||
|
ver.put("Build.PRODUCT", Build.PRODUCT);
|
||||||
|
return ver;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getRandomHexString(int n) {
|
||||||
|
Random r = new Random();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
while(sb.length() < n)
|
||||||
|
sb.append(Integer.toHexString(r.nextInt()));
|
||||||
|
return sb.toString().toUpperCase().substring(0, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/src/main/java/net/heartshield/data/AppMeta.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
import net.heartshield.util.JsonUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta-data about the app, such as its unique ID and install date.
|
||||||
|
* App ID assignments are privacy relevant.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-07
|
||||||
|
*/
|
||||||
|
public class AppMeta {
|
||||||
|
public String version;
|
||||||
|
public String codename;
|
||||||
|
|
||||||
|
/** client git hash */
|
||||||
|
@SerializedName("git_hash")
|
||||||
|
public String gitHash;
|
||||||
|
|
||||||
|
/** client build type - debug or release? */
|
||||||
|
@SerializedName("build_type")
|
||||||
|
public String buildType;
|
||||||
|
|
||||||
|
/** exact version code of Android package */
|
||||||
|
@SerializedName("version_code")
|
||||||
|
public String versionCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC unix timestamp in seconds
|
||||||
|
*/
|
||||||
|
@SerializedName("install_date")
|
||||||
|
public long installDate;
|
||||||
|
|
||||||
|
@SerializedName("install_android_versions")
|
||||||
|
public AndroidMeta installAndroidVersions;
|
||||||
|
|
||||||
|
@SerializedName("id")
|
||||||
|
public String appId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC unix timestamp in seconds
|
||||||
|
*/
|
||||||
|
@SerializedName("first_install_date")
|
||||||
|
public long firstInstallDate;
|
||||||
|
|
||||||
|
public String serialize() {
|
||||||
|
return JsonUtil.getGson().toJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppMeta deserialize(String json) {
|
||||||
|
return JsonUtil.getGson().fromJson(json, AppMeta.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AndroidMeta {
|
||||||
|
@SerializedName("os.version")
|
||||||
|
public String osVersion;
|
||||||
|
@SerializedName("Build.VERSION.INCREMENTAL")
|
||||||
|
public String buildVersionIncremental;
|
||||||
|
@SerializedName("Build.VERSION.SDK_INT")
|
||||||
|
public String buildVersionSdkInt;
|
||||||
|
@SerializedName("Build.DEVICE")
|
||||||
|
public String buildDevice;
|
||||||
|
@SerializedName("Build.MODEL")
|
||||||
|
public String buildModel;
|
||||||
|
@SerializedName("Build.PRODUCT")
|
||||||
|
public String buildProduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-27
|
||||||
|
*/
|
||||||
|
public class DataManagerFactory {
|
||||||
|
private static DataManagerFactory sInstance = null;
|
||||||
|
|
||||||
|
public IMeasurementDataManager getDataManager(Context context) {
|
||||||
|
AppInfo appInfo = AppInfo.getInstance(context);
|
||||||
|
|
||||||
|
if(appInfo.devSettings.useMockData)
|
||||||
|
return MockMeasurementDataManager.getInstance(appInfo.getAppMeta().appId);
|
||||||
|
else
|
||||||
|
return MeasurementDataManager.getInstance(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DataManagerFactory getInstance() {
|
||||||
|
if(sInstance == null) {
|
||||||
|
sInstance = new DataManagerFactory();
|
||||||
|
}
|
||||||
|
return sInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/src/main/java/net/heartshield/data/DoctorDetails.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import net.heartshield.util.JsonUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail notes of doctors regarding a Measurement.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-06-05
|
||||||
|
*/
|
||||||
|
public class DoctorDetails {
|
||||||
|
/** change time in UTC seconds since the Unix epoch (Unix timestamp) */
|
||||||
|
public long time;
|
||||||
|
|
||||||
|
public boolean smoker;
|
||||||
|
public boolean diabetes;
|
||||||
|
public boolean hypertension;
|
||||||
|
public boolean cvd;
|
||||||
|
public boolean no_cvd;
|
||||||
|
public boolean no_afib;
|
||||||
|
public boolean afib_sin;
|
||||||
|
public boolean afib_now;
|
||||||
|
public boolean no_cad;
|
||||||
|
public boolean cad_lt70;
|
||||||
|
public boolean cad_gt70;
|
||||||
|
public boolean betablocker;
|
||||||
|
public boolean antiarrhythmic;
|
||||||
|
public boolean antihypertensive;
|
||||||
|
|
||||||
|
public String bp_sys;
|
||||||
|
public String bp_dia;
|
||||||
|
public String chol_hdl;
|
||||||
|
public String chol_total;
|
||||||
|
public String other_med;
|
||||||
|
public String other_diag;
|
||||||
|
|
||||||
|
/** happiness rating on a 0-100 scale slider. 0 is invalid. */
|
||||||
|
public Integer happiness;
|
||||||
|
/** sleep quality rating on a 0-100 scale slider. 0 is invalid. */
|
||||||
|
public Integer sleep_quality;
|
||||||
|
|
||||||
|
public String serialize() {
|
||||||
|
return JsonUtil.getGson().toJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DoctorDetails deserialize(String json) {
|
||||||
|
return JsonUtil.getGson().fromJson(json, DoctorDetails.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
114
app/src/main/java/net/heartshield/data/FileUtil.java
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileFilter;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-10
|
||||||
|
*/
|
||||||
|
public class FileUtil {
|
||||||
|
public static final String PERSISTENT_DATA_DIR = "/hsh/data"; // data directory on SD card
|
||||||
|
static final String PERSISTENT_APP_INFO_DIR = "/hsh";
|
||||||
|
static final String PERSISTENT_APP_INFO = "app_id_info.b";
|
||||||
|
static final String MEASUREMENT_CACHE = "measurements.json";
|
||||||
|
|
||||||
|
private File mStorageDirectory;
|
||||||
|
|
||||||
|
public FileUtil(File storageDirectory) {
|
||||||
|
mStorageDirectory = storageDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getStorageDirectory() {
|
||||||
|
return mStorageDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void makeDirectories(String sdcardPath) {
|
||||||
|
String dirName = mStorageDirectory.getAbsolutePath() + sdcardPath;
|
||||||
|
File dir = new File(dirName);
|
||||||
|
if(!dir.exists())
|
||||||
|
if(!dir.mkdirs())
|
||||||
|
throw new RuntimeException("could not create dir in " + dirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String readFileString(InputStream is) throws IOException {
|
||||||
|
BufferedReader buf = new BufferedReader(new InputStreamReader(is));
|
||||||
|
String line = buf.readLine();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
while (line != null) {
|
||||||
|
sb.append(line).append("\n");
|
||||||
|
line = buf.readLine();
|
||||||
|
}
|
||||||
|
buf.close();
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] readFileBytes(InputStream is) throws IOException {
|
||||||
|
byte[] buf = new byte[1024];
|
||||||
|
ByteArrayOutputStream data = new ByteArrayOutputStream();
|
||||||
|
int len;
|
||||||
|
while((len = is.read(buf)) != -1)
|
||||||
|
data.write(buf, 0, len);
|
||||||
|
return data.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String readDataFileString(String relFileName) throws IOException {
|
||||||
|
File f = new File(mStorageDirectory.getAbsolutePath() + FileUtil.PERSISTENT_DATA_DIR, relFileName);
|
||||||
|
return readFileString(new FileInputStream(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] readDataFileBytes(String relFileName) throws IOException {
|
||||||
|
File f = new File(mStorageDirectory.getAbsolutePath() + FileUtil.PERSISTENT_DATA_DIR, relFileName);
|
||||||
|
return readFileBytes(new FileInputStream(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeDataFileContents(String relFileName, String contents) throws IOException {
|
||||||
|
File info = new File(mStorageDirectory.getAbsolutePath() + FileUtil.PERSISTENT_DATA_DIR, relFileName);
|
||||||
|
FileOutputStream os = new FileOutputStream(info);
|
||||||
|
os.write(contents.getBytes());
|
||||||
|
os.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeDataFileContents(String relFileName, byte[] contents) throws IOException {
|
||||||
|
File info = new File(mStorageDirectory.getAbsolutePath() + FileUtil.PERSISTENT_DATA_DIR, relFileName);
|
||||||
|
FileOutputStream os = new FileOutputStream(info);
|
||||||
|
os.write(contents);
|
||||||
|
os.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean dataFileExists(String relFileName) {
|
||||||
|
File info = new File(mStorageDirectory.getAbsolutePath() + FileUtil.PERSISTENT_DATA_DIR, relFileName);
|
||||||
|
return info.exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean removeDataFile(String relFileName) throws IOException {
|
||||||
|
File info = new File(mStorageDirectory.getAbsolutePath() + FileUtil.PERSISTENT_DATA_DIR, relFileName);
|
||||||
|
return info.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public File[] listDataDir(final String fileSuffix) {
|
||||||
|
File dataDir = new File(mStorageDirectory.getAbsolutePath() + FileUtil.PERSISTENT_DATA_DIR);
|
||||||
|
File[] files = dataDir.listFiles(new FileFilter() {
|
||||||
|
@Override
|
||||||
|
public boolean accept(File pathname) {
|
||||||
|
return pathname.getName().endsWith(fileSuffix);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Arrays.sort(files, new Comparator<File>() {
|
||||||
|
@Override
|
||||||
|
public int compare(File o1, File o2) {
|
||||||
|
return o1.getAbsolutePath().compareTo(o2.getAbsolutePath());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/src/main/java/net/heartshield/data/IAppInfo.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides AppMeta.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-07
|
||||||
|
*/
|
||||||
|
public interface IAppInfo {
|
||||||
|
AppMeta getAppMeta();
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface to remote server and local storage.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-07
|
||||||
|
*/
|
||||||
|
public interface IMeasurementDataManager {
|
||||||
|
/** save a new measurement, and ask the remote server for the classification result */
|
||||||
|
void create(Measurement measurement, MeasurementResultListener listener);
|
||||||
|
|
||||||
|
void logFailure(Measurement measurement, MeasurementResultListener listener);
|
||||||
|
|
||||||
|
void email(Measurement measurement, MeasurementResultListener listener);
|
||||||
|
|
||||||
|
/** retrieve measurement from local storage */
|
||||||
|
Measurement retrieve(MeasurementId id) throws IOException;
|
||||||
|
|
||||||
|
byte[] retrieveAudio(MeasurementId id) throws IOException;
|
||||||
|
|
||||||
|
/** update existing measurement (meta-data) */
|
||||||
|
void update(Measurement measurement, MeasurementResultListener listener);
|
||||||
|
|
||||||
|
/** list all measurements in ascending order (most recent last) */
|
||||||
|
MeasurementSummary[] list();
|
||||||
|
|
||||||
|
/** get report image that was transmitted by the server, or null if has no report image (yet) */
|
||||||
|
Bitmap getReportImage(MeasurementId id);
|
||||||
|
|
||||||
|
// these are internal, really. used for testing SyncTaskQueue.
|
||||||
|
// TODO: extract into separate interface
|
||||||
|
void createAndPost(String endpoint, Measurement measurement, MeasurementResultListener listener);
|
||||||
|
void putRemoteAudio(final Measurement measurement, final MeasurementResultListener listener);
|
||||||
|
boolean isBusy(Measurement measurement);
|
||||||
|
void setBusy(Measurement measurement, boolean busy);
|
||||||
|
void setBusyAudio(Measurement measurement, boolean busy);
|
||||||
|
boolean removeFromCache(MeasurementId id) throws IOException;
|
||||||
|
|
||||||
|
interface MeasurementResultListener {
|
||||||
|
/** result is available from the server */
|
||||||
|
void onResult(Measurement measurement);
|
||||||
|
|
||||||
|
/** no result is available, e.g. connection or protocol error */
|
||||||
|
void onError(Measurement measurement, ErrorCode code, String message);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PingResultListener {
|
||||||
|
void onResult();
|
||||||
|
void onError();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivationResultListener {
|
||||||
|
void onResult(String appId);
|
||||||
|
void onError(String reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
void activate(String activationCode, ActivationResultListener listener);
|
||||||
|
|
||||||
|
void ping(String appId, PingResultListener listener);
|
||||||
|
|
||||||
|
interface MeasurementSummaryListener {
|
||||||
|
void onMeasurementCreated(Measurement measurement);
|
||||||
|
void onMeasurementUpdated(Measurement measurement);
|
||||||
|
}
|
||||||
|
void setSummaryListener(MeasurementSummaryListener listener);
|
||||||
|
|
||||||
|
|
||||||
|
void setUseTestServer(boolean useTestServer);
|
||||||
|
boolean isUnderTest();
|
||||||
|
|
||||||
|
void startBackgroundThread();
|
||||||
|
void stopBackgroundThread();
|
||||||
|
|
||||||
|
// stops with stopBackgroundThread() after the current sync is done.
|
||||||
|
void startBackgroundSync();
|
||||||
|
|
||||||
|
interface SyncListener {
|
||||||
|
/** called on start() and after every single measurement sync */
|
||||||
|
void onProgress(int synced, int candidates);
|
||||||
|
|
||||||
|
/** finished, whether completed or not */
|
||||||
|
void onFinished(int synced, int candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSyncListener(SyncListener listener);
|
||||||
|
|
||||||
|
void addFailedMeasurement();
|
||||||
|
int getNumFailedMeasurements();
|
||||||
|
|
||||||
|
void logMessage(String appId, String mtype, String message);
|
||||||
|
|
||||||
|
enum ErrorCode {
|
||||||
|
LOCAL_ERROR,
|
||||||
|
UNKNOWN_ERROR,
|
||||||
|
CONNECTION_ERROR,
|
||||||
|
HTTP_ERROR,
|
||||||
|
PROTOCOL_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/src/main/java/net/heartshield/data/Measurement.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import android.icu.util.Measure;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonSerializationContext;
|
||||||
|
import com.google.gson.JsonSerializer;
|
||||||
|
|
||||||
|
import net.heartshield.util.JsonUtil;
|
||||||
|
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data model for a single measurement.
|
||||||
|
* Includes waveform data and metadata (time, device, user age/gender/risk factors/annotations)
|
||||||
|
*
|
||||||
|
* For server sync, a Measurement is uniquely identified globally by MeasurementId (app_id, start_time).
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-06
|
||||||
|
*/
|
||||||
|
public class Measurement {
|
||||||
|
// DTO: all public members are serialized to JSON (both for local persistence and to transmit to remote server)
|
||||||
|
|
||||||
|
/** start time of the measurement in UTC seconds since the Unix epoch (Unix timestamp) */
|
||||||
|
public final long startTime;
|
||||||
|
|
||||||
|
/** globally unique identifier of the client app */
|
||||||
|
public final String appId;
|
||||||
|
|
||||||
|
public MeasurementMeta meta;
|
||||||
|
public AppMeta app = null;
|
||||||
|
public UserMeta user = null;
|
||||||
|
public MeasurementSeries series = null;
|
||||||
|
public DoctorDetails doctorDetails = null;
|
||||||
|
public MeasurementResult result = null;
|
||||||
|
|
||||||
|
/** whether this Measurement has been changed, and should be transmitted to the server. */
|
||||||
|
@NoRemote
|
||||||
|
public boolean dirty = true;
|
||||||
|
|
||||||
|
public Measurement(String appId, long startTime, TimeZone timeZone) {
|
||||||
|
this.startTime = startTime;
|
||||||
|
this.appId = appId;
|
||||||
|
this.meta = new MeasurementMeta(timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Measurement(String appId, long unixTimestamp) {
|
||||||
|
this(appId, unixTimestamp, TimeZone.getDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Measurement(String appId) {
|
||||||
|
this(appId, System.currentTimeMillis() / 1000, TimeZone.getDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeasurementId getId() {
|
||||||
|
return new MeasurementId(appId, startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeasurementSummary getSummary() {
|
||||||
|
if(result != null && result.status == MeasurementResult.Status.OK) {
|
||||||
|
return new MeasurementSummary(startTime, appId, MeasurementSummary.Status.OK, getRiskLevel(), (int) (100.0 * result.pred), (int) result.getHeartRate(), series.audioSynced, dirty);
|
||||||
|
} else if(result != null && result.status == MeasurementResult.Status.UNCLASSIFIED) {
|
||||||
|
return new MeasurementSummary(startTime, appId, MeasurementSummary.Status.UNCLASSIFIED, getRiskLevel(), 0, 0, series.audioSynced, dirty);
|
||||||
|
} else {
|
||||||
|
boolean audioSynced = series != null && series.audioSynced;
|
||||||
|
return new MeasurementSummary(startTime, appId, MeasurementSummary.Status.OFFLINE, getRiskLevel(), 0, 0, audioSynced, dirty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRiskLevel() {
|
||||||
|
if(result == null)
|
||||||
|
return 0;
|
||||||
|
return result.getRiskLevel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether this Measurement has been transmitted before, and hence has a result */
|
||||||
|
public boolean hasResult() { return result != null; }
|
||||||
|
|
||||||
|
/** @return false if this Measurement needs to be transferred to the server. */
|
||||||
|
public boolean isSynced() {
|
||||||
|
return result != null && series.audioSynced;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String serialize(JsonUtil.Target target) {
|
||||||
|
return JsonUtil.getGson(target).toJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Measurement deserialize(String json) {
|
||||||
|
return JsonUtil.getGson().fromJson(json, Measurement.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if(obj == null || !(obj instanceof Measurement))
|
||||||
|
return false;
|
||||||
|
Measurement other = (Measurement) obj;
|
||||||
|
return (this.startTime == other.startTime && this.appId.equals(other.appId));
|
||||||
|
}
|
||||||
|
}
|
||||||
1019
app/src/main/java/net/heartshield/data/MeasurementDataManager.java
Normal file
38
app/src/main/java/net/heartshield/data/MeasurementId.java
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary key for identifying a Measurement.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-10
|
||||||
|
*/
|
||||||
|
public class MeasurementId implements Serializable {
|
||||||
|
public String appId;
|
||||||
|
public long startTime;
|
||||||
|
|
||||||
|
public MeasurementId(String appId, long startTime) {
|
||||||
|
this.appId = appId;
|
||||||
|
this.startTime = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MeasurementId parse(String measurementId) {
|
||||||
|
int underscore = measurementId.indexOf("_");
|
||||||
|
if(underscore == -1)
|
||||||
|
throw new IllegalArgumentException("could not parse measurementId " + measurementId + " since it did not contain '_'.");
|
||||||
|
return new MeasurementId(measurementId.substring(underscore+1), Long.parseLong(measurementId.substring(0, underscore)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
return startTime + "_" + appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if(obj == null || !(obj instanceof MeasurementId))
|
||||||
|
return false;
|
||||||
|
MeasurementId other = (MeasurementId) obj;
|
||||||
|
return (this.startTime == other.startTime && this.appId.equals(other.appId));
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/src/main/java/net/heartshield/data/MeasurementMeta.java
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.sensors.IAudioSensor;
|
||||||
|
import net.heartshield.sensors.IntensityDetector;
|
||||||
|
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta-data on a measurement, the device, and the user.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-07
|
||||||
|
*/
|
||||||
|
public class MeasurementMeta {
|
||||||
|
// DTO: all public members are serialized to JSON (both for local persistence and to transmit to remote server)
|
||||||
|
|
||||||
|
//public final TimeZone timeZone; // we store String instead
|
||||||
|
// "ZoneInfo declares multiple JSON fields named serialVersionUID" on Android, fine in test harness on PC
|
||||||
|
public final String timeZone;
|
||||||
|
|
||||||
|
/** time when camera A3 was locked, measured in seconds since the start of measurement, `startTime` */
|
||||||
|
public double lockTime;
|
||||||
|
|
||||||
|
/** times when camera A3 was locked, see `lockTime` */
|
||||||
|
public double[] lockTimes;
|
||||||
|
/** times when camera A3 was unlocked, see `lockTime` */
|
||||||
|
public double[] unlockTimes;
|
||||||
|
|
||||||
|
/** time when audio recording starts, measured in seconds since the start of measurement, `startTime` */
|
||||||
|
public double audioStartTime;
|
||||||
|
|
||||||
|
public IAudioSensor.AudioStartTimeAccuracy audioStartTimeAccuracy;
|
||||||
|
|
||||||
|
/** native byte order, for audio PCM */
|
||||||
|
public boolean littleEndian;
|
||||||
|
|
||||||
|
/** whether this is a test/replay that should not be stored on the server database */
|
||||||
|
public boolean test;
|
||||||
|
|
||||||
|
public double ppgMeanFps;
|
||||||
|
public double audioFps;
|
||||||
|
public double bcgFps;
|
||||||
|
|
||||||
|
/** number of dropped camera frames */
|
||||||
|
public int ppgNumDropped;
|
||||||
|
|
||||||
|
public IntensityDetector.CameraFeatures cameraFeatures;
|
||||||
|
|
||||||
|
/** JSON string describing and logging camera parameters actually retrieved and used */
|
||||||
|
public String cameraMeta;
|
||||||
|
|
||||||
|
/** type/purpose of the measurement */
|
||||||
|
public IMeasurementController.MeasurementMode mode;
|
||||||
|
|
||||||
|
/** whether this Measurement failed the client signal checks, and should hence stay UNCLASSIFIED */
|
||||||
|
public boolean failed = false;
|
||||||
|
|
||||||
|
public MeasurementMeta(TimeZone timeZone) {
|
||||||
|
this.timeZone = timeZone.getID();
|
||||||
|
this.littleEndian = ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/src/main/java/net/heartshield/data/MeasurementResult.java
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.IVec;
|
||||||
|
import net.heartshield.signal.Signal;
|
||||||
|
import net.heartshield.util.JsonUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data on a measurement, as provided by the server.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-07
|
||||||
|
*/
|
||||||
|
public class MeasurementResult {
|
||||||
|
/** confidence that coronary artery disease is present (probability score in [0,1]) */
|
||||||
|
// TODO: this should be Double, to enable null value for unclassified
|
||||||
|
public double pred;
|
||||||
|
/** filtered samples of the PPG signal */
|
||||||
|
public double[] filtered;
|
||||||
|
/** sample indices in `filtered` detected as heartbeats */
|
||||||
|
public int[] idx;
|
||||||
|
/** inter-beat intervals in MILLISECONDS */
|
||||||
|
public double[] ibis;
|
||||||
|
/** whether server succeeded in classifying the measurement */
|
||||||
|
public Status status;
|
||||||
|
|
||||||
|
/** unique token returned by server that identifies this Measurement (aka "Measurement ID" on UI) */
|
||||||
|
public String token;
|
||||||
|
|
||||||
|
/** error message or other reason for the status */
|
||||||
|
public String reason;
|
||||||
|
|
||||||
|
public String serialize() {
|
||||||
|
return JsonUtil.getGson().toJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MeasurementResult deserialize(String json) {
|
||||||
|
return JsonUtil.getGson().fromJson(json, MeasurementResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
@SerializedName("ok")
|
||||||
|
OK,
|
||||||
|
@SerializedName("unclassified")
|
||||||
|
UNCLASSIFIED,
|
||||||
|
@SerializedName("recorded")
|
||||||
|
RECORDED
|
||||||
|
// TODO: 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HrvTimeDomain {
|
||||||
|
public double rmssd;
|
||||||
|
public double sdnn;
|
||||||
|
public double pnn50;
|
||||||
|
public double hrv_tri;
|
||||||
|
}
|
||||||
|
public static class HrvFrequencyDomain {
|
||||||
|
public double total_power;
|
||||||
|
public double lf;
|
||||||
|
public double hf;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SerializedName("hrv_time_domain")
|
||||||
|
public HrvTimeDomain hrvTimeDomain;
|
||||||
|
|
||||||
|
@SerializedName("hrv_frequency_domain")
|
||||||
|
public HrvFrequencyDomain hrvFrequencyDomain;
|
||||||
|
|
||||||
|
public boolean isHealthy() {
|
||||||
|
if(!status.equals(Status.OK))
|
||||||
|
throw new IllegalStateException("no result score");
|
||||||
|
return this.pred <= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRiskLevel() {
|
||||||
|
int level;
|
||||||
|
|
||||||
|
// TODO: get 50th percentile for age group from server and use it for low/medium threshold (unless 50th percentile HS Score is >= 0.5)
|
||||||
|
/*
|
||||||
|
if(status.equals(Status.UNCLASSIFIED))
|
||||||
|
level = 0;
|
||||||
|
else if(pred < 0.4)
|
||||||
|
level = 1;
|
||||||
|
else if(isHealthy()) // <= 0.5
|
||||||
|
level = 2;
|
||||||
|
else
|
||||||
|
level = 3; // > 0.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
if(status.equals(Status.UNCLASSIFIED) || status.equals(Status.RECORDED))
|
||||||
|
level = 0;
|
||||||
|
else if(isHealthy()) // <= 0.5
|
||||||
|
level = 1;
|
||||||
|
else
|
||||||
|
level = 2; // > 0.5
|
||||||
|
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getHeartRate() {
|
||||||
|
if(this.ibis == null || this.ibis.length == 0)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
// ibis = ibis / 1e3
|
||||||
|
double[] ibis = DVec.mul(this.ibis, 1.0 / 1e3);
|
||||||
|
|
||||||
|
// hr = 60.0 / np.mean(ibis[np.where((ibis>0.4)&(ibis<1.4))[0]])
|
||||||
|
double[] goodIbis = Signal.goodIbis(ibis);
|
||||||
|
if(goodIbis.length == 0)
|
||||||
|
return 0.0;
|
||||||
|
double meanIbi = DVec.mean(goodIbis);
|
||||||
|
return 60.0 / meanIbi;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.Expose;
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waveform series data of a measurement.
|
||||||
|
*
|
||||||
|
* Contains time series of various sensors:
|
||||||
|
* * camera brightness: finger photoplethysmogram,
|
||||||
|
* * acceleration: motion during the measurement,
|
||||||
|
* * audio: ECG if available
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-07
|
||||||
|
*/
|
||||||
|
public class MeasurementSeries {
|
||||||
|
// timestamps of `ppgData` and `bcgData` are measured in seconds since the start of measurement, `startTime`
|
||||||
|
@SerializedName("ppg_data")
|
||||||
|
public List<double[]> ppgData;
|
||||||
|
|
||||||
|
@SerializedName("bcg_data")
|
||||||
|
public List<double[]> bcgData;
|
||||||
|
|
||||||
|
private byte[] audioData = null;
|
||||||
|
|
||||||
|
@SerializedName("audio_start")
|
||||||
|
public double audioStartTime;
|
||||||
|
|
||||||
|
/** whether client has successfully transmitted the audioData */
|
||||||
|
@NoRemote
|
||||||
|
//@Expose(serialize = false, deserialize = false)
|
||||||
|
public boolean audioSynced = false;
|
||||||
|
|
||||||
|
public void setAudio(byte[] audio, double audioStartTime) {
|
||||||
|
this.audioData = audio;
|
||||||
|
this.audioStartTime = audioStartTime;
|
||||||
|
this.audioSynced = false;
|
||||||
|
}
|
||||||
|
public byte[] getAudio() { return this.audioData; }
|
||||||
|
//public double getAudioStartTime() { return this.audioStartTime; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measurement information displayed in ResultListActivity and cached for performance.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-20
|
||||||
|
*/
|
||||||
|
public class MeasurementSummary {
|
||||||
|
/** start time of the measurement in UTC seconds since the Unix epoch (Unix timestamp) */
|
||||||
|
public long startTime;
|
||||||
|
|
||||||
|
/** globally unique identifier of the client app */
|
||||||
|
public String appId;
|
||||||
|
|
||||||
|
/** local number of the measurement (only valid on the same device) */
|
||||||
|
public int number;
|
||||||
|
|
||||||
|
/** whether the measurement was sent, and whether classification itself was successful (else unclassified) */
|
||||||
|
public Status status;
|
||||||
|
|
||||||
|
/** risk level 0-3: 0 is unknown, 1-3 is low, medium, high */
|
||||||
|
public int riskLevel;
|
||||||
|
|
||||||
|
/** score in range 0-100: confidence that coronary artery disease is present (probability score in [0,1]) */
|
||||||
|
public int heartScore;
|
||||||
|
|
||||||
|
/** beats per minute */
|
||||||
|
public int bpm;
|
||||||
|
|
||||||
|
/** whether audioData was successfully transmitted */
|
||||||
|
public boolean audioSynced;
|
||||||
|
|
||||||
|
/** whether this Measurement should be transmitted to the server */
|
||||||
|
public boolean dirty;
|
||||||
|
|
||||||
|
public MeasurementSummary() {}
|
||||||
|
|
||||||
|
public MeasurementSummary(long startTime, String appId, Status status, int riskLevel, int heartScore, int bpm, boolean audioSynced, boolean dirty) {
|
||||||
|
this.startTime = startTime;
|
||||||
|
this.appId = appId;
|
||||||
|
//this.number = number;
|
||||||
|
this.number = 0;
|
||||||
|
this.status = status;
|
||||||
|
this.riskLevel = riskLevel;
|
||||||
|
this.heartScore = heartScore;
|
||||||
|
this.bpm = bpm;
|
||||||
|
this.audioSynced = audioSynced;
|
||||||
|
this.dirty = dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeasurementId getId() {
|
||||||
|
return new MeasurementId(appId, startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return false if this Measurement needs to be transferred to the server. */
|
||||||
|
public boolean isSynced() {
|
||||||
|
return status != Status.OFFLINE && audioSynced && !dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if(obj == null)
|
||||||
|
return false;
|
||||||
|
if(!(obj instanceof MeasurementSummary))
|
||||||
|
return false;
|
||||||
|
MeasurementSummary other = (MeasurementSummary) obj;
|
||||||
|
return getId().equals(other.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
OK,
|
||||||
|
UNCLASSIFIED,
|
||||||
|
OFFLINE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.IVec;
|
||||||
|
import net.heartshield.signal.Series;
|
||||||
|
import net.heartshield.signal.Signal;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class MockMeasurementDataManager implements IMeasurementDataManager {
|
||||||
|
public boolean created = false;
|
||||||
|
public boolean updated = false;
|
||||||
|
public boolean putAudio = false;
|
||||||
|
|
||||||
|
private List<Measurement> mMeasurements = new ArrayList<>();
|
||||||
|
|
||||||
|
private static MockMeasurementDataManager sInstance = null;
|
||||||
|
|
||||||
|
private MockMeasurementDataManager(String appId) {
|
||||||
|
populate(appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populate(String appId) {
|
||||||
|
if(mMeasurements.size() > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
mMeasurements.add(fakePPG(appId, (long) 1.4e9 + 100, 25, 60, 0.05, 1.0));
|
||||||
|
mMeasurements.add(fakePPG(appId, (long) 1.4e9 + 200, 45, 75, 0.45, 0.3));
|
||||||
|
mMeasurements.add(fakePPG(appId, (long) 1.4e9 + 300, 50, 90, 0.55, 0.0));
|
||||||
|
mMeasurements.add(fakePPG(appId, (long) 1.4e9 + 400, 99, 0, 0.0, 0.0)); // unclassified
|
||||||
|
mMeasurements.add(failedTransmissionPPG(appId, (long) 1.4e9 + 500, 99)); // failed transmission
|
||||||
|
}
|
||||||
|
|
||||||
|
private Measurement failedTransmissionPPG(String appId, long ts, int age) {
|
||||||
|
Measurement low;
|
||||||
|
low = new Measurement(appId, ts);
|
||||||
|
low.user = new UserMeta(age, UserMeta.Gender.MALE, 1.96);
|
||||||
|
low.result = null;
|
||||||
|
|
||||||
|
low.series = new MeasurementSeries();
|
||||||
|
low.series.audioSynced = true;
|
||||||
|
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Measurement fakePPG(String appId, long ts, int age, int bpm, double pred, double harmonicAmplitude) {
|
||||||
|
Measurement low;
|
||||||
|
low = new Measurement(appId, ts);
|
||||||
|
low.user = new UserMeta(age, UserMeta.Gender.MALE, 1.96);
|
||||||
|
|
||||||
|
MeasurementResult result = new MeasurementResult();
|
||||||
|
Series ppg = fakeElasticPPG(bpm, harmonicAmplitude);
|
||||||
|
result.ibis = DVec.mul(DVec.ones(bpm), 1e3 * 60.0 / ((double) bpm));
|
||||||
|
result.filtered = ppg.x;
|
||||||
|
result.idx = IVec.where(Signal.beatdetect(30.0, ppg.x));
|
||||||
|
result.status = (bpm != 0) ? MeasurementResult.Status.OK : MeasurementResult.Status.UNCLASSIFIED;
|
||||||
|
result.pred = pred;
|
||||||
|
low.result = result;
|
||||||
|
|
||||||
|
result.hrvTimeDomain = new MeasurementResult.HrvTimeDomain();
|
||||||
|
result.hrvFrequencyDomain = new MeasurementResult.HrvFrequencyDomain();
|
||||||
|
|
||||||
|
low.series = new MeasurementSeries();
|
||||||
|
low.series.audioSynced = true;
|
||||||
|
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Series fakeElasticPPG(int bpm, double harmonicAmplitude) {
|
||||||
|
/*
|
||||||
|
|
||||||
|
t = np.arange(0, 60, 1./30.)
|
||||||
|
x = np.sin(2*np.pi*1.0*t)
|
||||||
|
alpha = np.pi*0.6
|
||||||
|
x += np.sin(np.exp(np.sin(2*np.pi*1.0*t + alpha)))
|
||||||
|
plt.plot(t[:300], x[:300])
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
*/
|
||||||
|
final double T = 60.0;
|
||||||
|
double[] t = DVec.arange(0, T, 1./30.);
|
||||||
|
final double f = ((double) bpm) / 60.0, alpha = Math.PI*0.6;
|
||||||
|
double[] x = DVec.sin(DVec.mul(t, 2*Math.PI*f));
|
||||||
|
x = DVec.add(x, DVec.mul(DVec.sin(DVec.exp(DVec.sin(DVec.add(DVec.mul(t, 2*Math.PI*f), alpha)))), harmonicAmplitude));
|
||||||
|
return new Series(t, x);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void ping(String appId, PingResultListener listener) {
|
||||||
|
listener.onResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void activate(String activationCode, ActivationResultListener listener) {
|
||||||
|
listener.onResult(activationCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void create(Measurement measurement, MeasurementResultListener listener) {
|
||||||
|
createAndPost("mock-measurement", measurement, listener);
|
||||||
|
if(!measurement.series.audioSynced)
|
||||||
|
putRemoteAudio(measurement, new MeasurementDataManager.StubListener());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void logFailure(Measurement measurement, MeasurementResultListener listener) {
|
||||||
|
createAndPost("mock-measurement", measurement, listener);
|
||||||
|
if(!measurement.series.audioSynced)
|
||||||
|
putRemoteAudio(measurement, new MeasurementDataManager.StubListener());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void email(Measurement measurement, MeasurementResultListener listener) {
|
||||||
|
create(measurement, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Measurement retrieve(MeasurementId id) throws IOException {
|
||||||
|
int len = mMeasurements.size();
|
||||||
|
for(int i = 0; i < len; i++) {
|
||||||
|
if(mMeasurements.get(i).getId().equals(id))
|
||||||
|
return mMeasurements.get(i);
|
||||||
|
}
|
||||||
|
throw new IOException("could not find mock Measurement " + id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] retrieveAudio(MeasurementId id) throws IOException {
|
||||||
|
throw new FileNotFoundException("mock not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(Measurement measurement, MeasurementResultListener listener) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MeasurementSummary[] list() {
|
||||||
|
MeasurementSummary[] summaries = new MeasurementSummary[mMeasurements.size()];
|
||||||
|
for(int i = 0; i < summaries.length; i++)
|
||||||
|
summaries[i] = mMeasurements.get(i).getSummary();
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Bitmap getReportImage(MeasurementId id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createAndPost(String endpoint, Measurement measurement, MeasurementResultListener listener) {
|
||||||
|
created = true;
|
||||||
|
// measurement.result would normally be set here.
|
||||||
|
listener.onResult(measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void putRemoteAudio(Measurement measurement, MeasurementResultListener listener) {
|
||||||
|
putAudio = true;
|
||||||
|
listener.onResult(measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setSummaryListener(MeasurementSummaryListener listener) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setUseTestServer(boolean useTestServer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isUnderTest() { return true; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stopBackgroundThread() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setSyncListener(SyncListener listener) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startBackgroundThread() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MockMeasurementDataManager getInstance(String appId) {
|
||||||
|
if(sInstance == null) {
|
||||||
|
sInstance = new MockMeasurementDataManager(appId);
|
||||||
|
}
|
||||||
|
return sInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private int mNumFailedMeasurements = 0;
|
||||||
|
|
||||||
|
public void addFailedMeasurement() {
|
||||||
|
mNumFailedMeasurements++;
|
||||||
|
}
|
||||||
|
public int getNumFailedMeasurements() {
|
||||||
|
return mNumFailedMeasurements;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void logMessage(String appId, String mtype, String message) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isBusy(Measurement measurement) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setBusy(Measurement measurement, boolean busy) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setBusyAudio(Measurement measurement, boolean busy) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean removeFromCache(MeasurementId id) throws IOException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startBackgroundSync() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/src/main/java/net/heartshield/data/NoRemote.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude a field from JSON serialization when sending to remote server.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-05
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.FIELD)
|
||||||
|
public @interface NoRemote {}
|
||||||
30
app/src/main/java/net/heartshield/data/TimeZoneAdapter.java
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import com.google.gson.JsonDeserializationContext;
|
||||||
|
import com.google.gson.JsonDeserializer;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.google.gson.JsonPrimitive;
|
||||||
|
import com.google.gson.JsonSerializationContext;
|
||||||
|
import com.google.gson.JsonSerializer;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google gson adapter for JSON serialization/deserialization of TimeZone objects.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-06
|
||||||
|
*/
|
||||||
|
public class TimeZoneAdapter implements JsonSerializer<TimeZone>, JsonDeserializer<TimeZone> {
|
||||||
|
@Override
|
||||||
|
public JsonElement serialize(TimeZone src, Type typeOfSrc, JsonSerializationContext context) {
|
||||||
|
return new JsonPrimitive(src.getID());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeZone deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||||
|
return TimeZone.getTimeZone(json.getAsString());
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/src/main/java/net/heartshield/data/UserMeta.java
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta-data on the user.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-07
|
||||||
|
*/
|
||||||
|
public class UserMeta implements Serializable {
|
||||||
|
public int age;
|
||||||
|
public Gender gender;
|
||||||
|
public Double height;
|
||||||
|
public String email;
|
||||||
|
public Boolean notifyBeta;
|
||||||
|
|
||||||
|
public UserMeta(int age, Gender gender, Double height) {
|
||||||
|
this.age = age;
|
||||||
|
this.gender = gender;
|
||||||
|
this.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Gender {
|
||||||
|
@SerializedName("female")
|
||||||
|
FEMALE,
|
||||||
|
@SerializedName("male")
|
||||||
|
MALE
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/src/main/java/net/heartshield/data/WavData.java
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package net.heartshield.data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to write PCM data to WAV file format.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-04
|
||||||
|
*/
|
||||||
|
public class WavData {
|
||||||
|
int channels;
|
||||||
|
int bitsPerSample;
|
||||||
|
int sampleRate;
|
||||||
|
int samples;
|
||||||
|
|
||||||
|
public WavData(int bodySizeBytes, int sampleRate, int channels, int bitsPerSample) {
|
||||||
|
int blockAlign = ((channels * bitsPerSample) / 8);
|
||||||
|
this.samples = bodySizeBytes / blockAlign;
|
||||||
|
this.sampleRate = sampleRate;
|
||||||
|
this.channels = channels;
|
||||||
|
this.bitsPerSample = bitsPerSample;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WAV file header size in bytes */
|
||||||
|
public static final int HEADER_SIZE = 44;
|
||||||
|
|
||||||
|
public byte[] waveHeader() {
|
||||||
|
// data format docs: see e.g. http://soundfile.sapp.org/doc/WaveFormat/
|
||||||
|
byte[] header = new byte[HEADER_SIZE];
|
||||||
|
|
||||||
|
int blockAlign = ((channels * bitsPerSample) / 8);
|
||||||
|
int byteRate = sampleRate * channels * bitsPerSample / 8;
|
||||||
|
|
||||||
|
int subchunk2Size = samples * channels * bitsPerSample / 8;
|
||||||
|
int chunkSize = 36 + subchunk2Size;
|
||||||
|
|
||||||
|
header[0] = 'R'; // RIFF/WAVE header
|
||||||
|
header[1] = 'I';
|
||||||
|
header[2] = 'F';
|
||||||
|
header[3] = 'F';
|
||||||
|
header[4] = (byte) (chunkSize & 0xff);
|
||||||
|
header[5] = (byte) ((chunkSize >> 8) & 0xff);
|
||||||
|
header[6] = (byte) ((chunkSize >> 16) & 0xff);
|
||||||
|
header[7] = (byte) ((chunkSize >> 24) & 0xff);
|
||||||
|
header[8] = 'W';
|
||||||
|
header[9] = 'A';
|
||||||
|
header[10] = 'V';
|
||||||
|
header[11] = 'E';
|
||||||
|
header[12] = 'f'; // 'fmt ' chunk
|
||||||
|
header[13] = 'm';
|
||||||
|
header[14] = 't';
|
||||||
|
header[15] = ' ';
|
||||||
|
header[16] = 16; // 4 bytes: size of 'fmt ' chunk
|
||||||
|
header[17] = 0;
|
||||||
|
header[18] = 0;
|
||||||
|
header[19] = 0;
|
||||||
|
header[20] = 1; // format = 1
|
||||||
|
header[21] = 0;
|
||||||
|
header[22] = (byte) (channels & 0xff);
|
||||||
|
header[23] = (byte) ((channels >> 8) & 0xff);
|
||||||
|
header[24] = (byte) (sampleRate & 0xff);
|
||||||
|
header[25] = (byte) ((sampleRate >> 8) & 0xff);
|
||||||
|
header[26] = (byte) ((sampleRate >> 16) & 0xff);
|
||||||
|
header[27] = (byte) ((sampleRate >> 24) & 0xff);
|
||||||
|
header[28] = (byte) (byteRate & 0xff);
|
||||||
|
header[29] = (byte) ((byteRate >> 8) & 0xff);
|
||||||
|
header[30] = (byte) ((byteRate >> 16) & 0xff);
|
||||||
|
header[31] = (byte) ((byteRate >> 24) & 0xff);
|
||||||
|
header[32] = (byte) blockAlign;
|
||||||
|
header[33] = 0;
|
||||||
|
header[34] = (byte) bitsPerSample;
|
||||||
|
header[35] = 0;
|
||||||
|
header[36] = 'd';
|
||||||
|
header[37] = 'a';
|
||||||
|
header[38] = 't';
|
||||||
|
header[39] = 'a';
|
||||||
|
header[40] = (byte) (subchunk2Size & 0xff);
|
||||||
|
header[41] = (byte) ((subchunk2Size >> 8) & 0xff);
|
||||||
|
header[42] = (byte) ((subchunk2Size >> 16) & 0xff);
|
||||||
|
header[43] = (byte) ((subchunk2Size >> 24) & 0xff);
|
||||||
|
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2014 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* 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 net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.TextureView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link TextureView} that can be adjusted to a specified aspect ratio.
|
||||||
|
*
|
||||||
|
* Copyright 2014 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
*
|
||||||
|
* @date 2017-05-26
|
||||||
|
*/
|
||||||
|
public class AutoFitTextureView extends TextureView {
|
||||||
|
|
||||||
|
private int mRatioWidth = 0;
|
||||||
|
private int mRatioHeight = 0;
|
||||||
|
|
||||||
|
public AutoFitTextureView(Context context) {
|
||||||
|
this(context, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutoFitTextureView(Context context, AttributeSet attrs) {
|
||||||
|
this(context, attrs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutoFitTextureView(Context context, AttributeSet attrs, int defStyle) {
|
||||||
|
super(context, attrs, defStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the aspect ratio for this view. The size of the view will be measured based on the ratio
|
||||||
|
* calculated from the parameters. Note that the actual sizes of parameters don't matter, that
|
||||||
|
* is, calling setAspectRatio(2, 3) and setAspectRatio(4, 6) make the same result.
|
||||||
|
*
|
||||||
|
* @param width Relative horizontal size
|
||||||
|
* @param height Relative vertical size
|
||||||
|
*/
|
||||||
|
public void setAspectRatio(int width, int height) {
|
||||||
|
if (width < 0 || height < 0) {
|
||||||
|
throw new IllegalArgumentException("Size cannot be negative.");
|
||||||
|
}
|
||||||
|
mRatioWidth = width;
|
||||||
|
mRatioHeight = height;
|
||||||
|
requestLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||||
|
int width = MeasureSpec.getSize(widthMeasureSpec);
|
||||||
|
int height = MeasureSpec.getSize(heightMeasureSpec);
|
||||||
|
if (0 == mRatioWidth || 0 == mRatioHeight) {
|
||||||
|
setMeasuredDimension(width, height);
|
||||||
|
} else {
|
||||||
|
if (width < height * mRatioWidth / mRatioHeight) {
|
||||||
|
setMeasuredDimension(width, width * mRatioHeight / mRatioWidth);
|
||||||
|
} else {
|
||||||
|
setMeasuredDimension(height * mRatioWidth / mRatioHeight, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-16
|
||||||
|
*/
|
||||||
|
public class BackgroundThread {
|
||||||
|
private HandlerThread mBackgroundThread;
|
||||||
|
private Handler mBackgroundHandler;
|
||||||
|
private String mName;
|
||||||
|
private boolean mEverStarted = false;
|
||||||
|
|
||||||
|
public BackgroundThread() {
|
||||||
|
mName = "Background";
|
||||||
|
}
|
||||||
|
public BackgroundThread(String name) {
|
||||||
|
mName = name;
|
||||||
|
}
|
||||||
|
public BackgroundThread(Class clazz) {
|
||||||
|
mName = clazz.getName() + "Background";
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Handler getHandler() {
|
||||||
|
return mBackgroundHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** lazy-post handler - discards tasks if we're not running anymore */
|
||||||
|
public synchronized void post(Runnable r) {
|
||||||
|
if(mBackgroundHandler == null) {
|
||||||
|
if(!mEverStarted)
|
||||||
|
throw new IllegalStateException("must start BackgroundThread before posting");
|
||||||
|
else
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mBackgroundHandler.post(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void start() {
|
||||||
|
mBackgroundThread = new HandlerThread(mName);
|
||||||
|
mBackgroundThread.start();
|
||||||
|
mBackgroundHandler = new Handler(mBackgroundThread.getLooper());
|
||||||
|
mEverStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void stop() {
|
||||||
|
if(mBackgroundThread != null) {
|
||||||
|
mBackgroundThread.quitSafely();
|
||||||
|
try {
|
||||||
|
mBackgroundThread.join();
|
||||||
|
mBackgroundThread = null;
|
||||||
|
mBackgroundHandler = null;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
761
app/src/main/java/net/heartshield/prevent/BreatheActivity.java
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.res.TypedArray;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.os.Vibrator;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.TextureView;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.github.mikephil.charting.charts.LineChart;
|
||||||
|
|
||||||
|
import net.heartshield.control.Device;
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.control.MeasurementController;
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.Measurement;
|
||||||
|
import net.heartshield.data.MeasurementDataManager;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
import net.heartshield.filter.PlotFilter;
|
||||||
|
import net.heartshield.sensors.IntensityDetector;
|
||||||
|
import net.heartshield.sensors.SensorFactory;
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.RunningQuality;
|
||||||
|
import net.heartshield.signal.Signal;
|
||||||
|
import net.heartshield.ui.CircleView;
|
||||||
|
import net.heartshield.ui.LineChartWrapper;
|
||||||
|
|
||||||
|
import static net.heartshield.prevent.MeasureActivity.LogMessageType.ERROR;
|
||||||
|
import static net.heartshield.prevent.MeasureActivity.LogMessageType.MEASUREMENT_FAILURE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measurement screen with countdown timer and running wave shape.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-01
|
||||||
|
*/
|
||||||
|
public class BreatheActivity extends Activity {
|
||||||
|
private static final String TAG = "MeasureActivity";
|
||||||
|
|
||||||
|
private UserMeta mUser;
|
||||||
|
private IMeasurementController.MeasurementMode mMode;
|
||||||
|
private IMeasurementController mController;
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private AppInfo mAppInfo;
|
||||||
|
|
||||||
|
private HandlerThread mBackgroundThread;
|
||||||
|
private Handler mBackgroundHandler;
|
||||||
|
|
||||||
|
private LineChartWrapper mChart;
|
||||||
|
PlotFilter mPlotFilter;
|
||||||
|
private static final double FPS = 30.0;
|
||||||
|
|
||||||
|
/** we require a lock (including SQI lock) by this amount of time, and fail the measurement otherwise. this should allow plenty of time to settle all swings and get solid beats detected. */
|
||||||
|
//final double REQUIRE_LOCK_BY_TIME = 30.0;
|
||||||
|
private static final double TOTAL_TIME_BREATHE = 70.0;
|
||||||
|
private static final double TOTAL_TIME_RISK = 75.0;
|
||||||
|
public static final double FRONT_TIME_ENDOTHELIAL_FUNCTION = 75.0;
|
||||||
|
private static final double TOTAL_TIME_ENDOTHELIAL_FUNCTION = FRONT_TIME_ENDOTHELIAL_FUNCTION + 300.0 + 75.0;
|
||||||
|
public static final double TOTAL_TIME_VITAL_CHECK = 10.0;
|
||||||
|
public static final double FRONT_TIME_VITAL_CHECK = 5.0; // until when it is OK to restart measurement on large swings (e.g. when placing the phone)
|
||||||
|
double mTotalTime;
|
||||||
|
//final double TOTAL_TIME = 15.0;
|
||||||
|
final boolean TEST_MODE = false; // if true, do not save Measurement on server
|
||||||
|
private CircleView mCircleView;
|
||||||
|
private TextView mTitleText;
|
||||||
|
private TextView mCountdownText;
|
||||||
|
private TextView mHeartRateText;
|
||||||
|
private ImageView mProgressCircle;
|
||||||
|
private ImageView mSignalIndicator;
|
||||||
|
private TextureView mCameraPreview = null;
|
||||||
|
private TypedArray mProgSequence;
|
||||||
|
private TypedArray mTxSequence;
|
||||||
|
|
||||||
|
private int mNumNoisy = 0;
|
||||||
|
private static final int MAX_NUM_NOISY = 15; // + give a few for Signal.beatdetect() errors.
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_breathe);
|
||||||
|
|
||||||
|
//mCameraPreview = (TextureView) findViewById(R.id.cameraPreview);
|
||||||
|
mCircleView = (CircleView) findViewById(R.id.circleView);
|
||||||
|
mTitleText = (TextView) findViewById(R.id.title);
|
||||||
|
mCountdownText = (TextView) findViewById(R.id.countdown);
|
||||||
|
mHeartRateText = (TextView) findViewById(R.id.heartRate);
|
||||||
|
mProgressCircle = (ImageView) findViewById(R.id.progressCircle);
|
||||||
|
mSignalIndicator = (ImageView) findViewById(R.id.signalIndicator);
|
||||||
|
mProgSequence = getResources().obtainTypedArray(R.array.progress_circle_sequence);
|
||||||
|
mTxSequence = getResources().obtainTypedArray(R.array.signal_transmit_sequence);
|
||||||
|
|
||||||
|
mHeartRateText.setText("- ");
|
||||||
|
mSignalIndicator.setImageResource(0);
|
||||||
|
mProgressCircle.invalidate();
|
||||||
|
|
||||||
|
mController = new MeasurementController(getMainLooper(), new Device(this), new SensorFactory(this), mCameraPreview);
|
||||||
|
mController.setListener(mListener);
|
||||||
|
|
||||||
|
// server does not save then. maybe we do want to save... maybe create another test flag, for not saving?
|
||||||
|
//if(AppInfo.getInstance(this).devSettings.skipDialogs)
|
||||||
|
// mController.setUnderTest(true);
|
||||||
|
|
||||||
|
mChart = new LineChartWrapper((LineChart) findViewById(R.id.chart));
|
||||||
|
final int PLOT_SAMPLES = (int) (FPS * 3.0); // see LineChartWrapper.WINDOW_SIZE_SECS
|
||||||
|
|
||||||
|
// TODO(david): PlotFilter should be more robust, not just TOTAL_TIME
|
||||||
|
mPlotFilter = new PlotFilter(FPS, (int) ((mTotalTime + 10.0) * FPS + 1), PLOT_SAMPLES);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* when integrating RunningQuality into MeasurementController, keep in mind the necessity
|
||||||
|
* of a separate background worker thread.
|
||||||
|
*/
|
||||||
|
mQuality = new RunningQuality(FPS);
|
||||||
|
mQuality.setListener(new RunningQuality.BeatListener() {
|
||||||
|
private int mBadBeatStartIndex = 0;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLocked(int startIdx) {
|
||||||
|
Log.i(TAG, "onLocked() at startIdx=" + startIdx);
|
||||||
|
mNumNoisy = 0;
|
||||||
|
mBadBeatStartIndex = 0;
|
||||||
|
|
||||||
|
double qualityLockTime = mQuality.getTimeAt(startIdx);
|
||||||
|
mController.setQualityLockTime(qualityLockTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBeat(int startIdx, boolean goodBeat, double posCorrCoeff) {
|
||||||
|
//double time = mQuality.getTimeAt(startIdx);
|
||||||
|
Log.i(TAG, "onBeat() SQI: posCorrCoeff=" + posCorrCoeff);
|
||||||
|
if(!goodBeat) {
|
||||||
|
mNumNoisy++;
|
||||||
|
|
||||||
|
double t = mQuality.getTimeAt(startIdx);
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT");
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT");
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT <<<<<<<< noisy beat #" + mNumNoisy);
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT");
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// ignored for doctors
|
||||||
|
if(mNumNoisy > MAX_NUM_NOISY) {
|
||||||
|
try {
|
||||||
|
mController.stop();
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
startErrorActivity(getString(R.string.frames_dropped));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMeasurementFailure(mController.getMeasurement());
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mDataManager = MeasurementDataManager.getInstance(getApplicationContext());
|
||||||
|
|
||||||
|
mAppInfo = AppInfo.getInstance(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SignalAnimator extends Thread {
|
||||||
|
private boolean running = true;
|
||||||
|
private int i;
|
||||||
|
|
||||||
|
SignalAnimator() {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized private boolean isRunning() { return running; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
i = 0;
|
||||||
|
|
||||||
|
while(isRunning()) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(250);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
//mSignalIndicator.setImageResource();
|
||||||
|
|
||||||
|
final int ii = i;
|
||||||
|
|
||||||
|
if(++i >= mTxSequence.length())
|
||||||
|
i = 0;
|
||||||
|
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mSignalIndicator.setImageResource(mTxSequence.getResourceId(ii, -1));
|
||||||
|
mSignalIndicator.invalidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void cancel() {
|
||||||
|
running = false;
|
||||||
|
interrupt();
|
||||||
|
try {
|
||||||
|
join();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SignalAnimator mAnimator;
|
||||||
|
|
||||||
|
private void startAnimation() {
|
||||||
|
if(mAnimator != null)
|
||||||
|
return; // already animating
|
||||||
|
mAnimator = new SignalAnimator();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopAnimation() {
|
||||||
|
if(mAnimator != null) {
|
||||||
|
mAnimator.cancel();
|
||||||
|
mAnimator = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
mV = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
mMode = (IMeasurementController.MeasurementMode) getIntent().getSerializableExtra("mode");
|
||||||
|
Log.i(TAG, "got age=" + mUser.age + " gender=" + mUser.gender + " mode=" + mMode);
|
||||||
|
|
||||||
|
if(mMode == null) {
|
||||||
|
// to do: e.g. 'Repeat measurement' button in ResultActivity
|
||||||
|
Log.w(TAG, "should migrate all calling Activities to include mode");
|
||||||
|
mMode = IMeasurementController.MeasurementMode.RISK;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(mMode) {
|
||||||
|
default: // fall through
|
||||||
|
case SWEEP_BREATHING:
|
||||||
|
mTotalTime = TOTAL_TIME_BREATHE;
|
||||||
|
break;
|
||||||
|
case RISK:
|
||||||
|
mTotalTime = TOTAL_TIME_RISK;
|
||||||
|
break;
|
||||||
|
case ENDOTHELIAL_FUNCTION:
|
||||||
|
mTotalTime = TOTAL_TIME_ENDOTHELIAL_FUNCTION;
|
||||||
|
break;
|
||||||
|
case VITAL_CHECK:
|
||||||
|
mTotalTime = TOTAL_TIME_VITAL_CHECK;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
mController.setMode(mMode);
|
||||||
|
|
||||||
|
mPlotFilter.clear();
|
||||||
|
mQuality.clear();
|
||||||
|
mController.start();
|
||||||
|
|
||||||
|
if(mDataManager.getNumFailedMeasurements() > 0)
|
||||||
|
mQuality.setBeatCorrThr2(RunningQuality.BEAT_CORR_THR_2_LOWER);
|
||||||
|
else
|
||||||
|
mQuality.setBeatCorrThr2(RunningQuality.BEAT_CORR_THR_2);
|
||||||
|
|
||||||
|
updateCircle(0.0);
|
||||||
|
|
||||||
|
mCountdownText.setText(Integer.toString((int) mTotalTime));
|
||||||
|
mProgressCircle.setImageResource(mProgSequence.getResourceId(0, -1));
|
||||||
|
mProgressCircle.invalidate();
|
||||||
|
|
||||||
|
mSignalIndicator.setImageResource(0);
|
||||||
|
mProgressCircle.invalidate();
|
||||||
|
|
||||||
|
startBackgroundThread();
|
||||||
|
mDataManager.startBackgroundThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onMeasurementFinished(Measurement measurement) {
|
||||||
|
updateMeasurement(measurement);
|
||||||
|
|
||||||
|
startAnimation();
|
||||||
|
|
||||||
|
// if dummy microphone, pretend we're already synced
|
||||||
|
if(measurement.series.getAudio().length == 0)
|
||||||
|
measurement.series.audioSynced = true;
|
||||||
|
|
||||||
|
mDataManager.create(measurement, new IMeasurementDataManager.MeasurementResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(Measurement measurement) {
|
||||||
|
//assert(measurement.result != null);
|
||||||
|
|
||||||
|
// successful result from server
|
||||||
|
Intent intent = new Intent(BreatheActivity.this, ResultActivity.class);
|
||||||
|
intent.putExtra("measurementId", measurement.getId());
|
||||||
|
startActivity(intent);
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
onCreateError(measurement, code, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onCreateError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
// some error saving the stuff
|
||||||
|
Toast.makeText(BreatheActivity.this, "Failed to get result: " + code + " " + message, Toast.LENGTH_LONG).show();
|
||||||
|
Log.e(TAG, "Failed to get result: " + code + " " + message);
|
||||||
|
stopAnimation();
|
||||||
|
|
||||||
|
int failureReason;
|
||||||
|
|
||||||
|
// note: UNCLASSIFIED is a regular, protocol-level response that is not an error.
|
||||||
|
// hence it is not handled here, but displayed in ResultActivity in a special way
|
||||||
|
switch(code) {
|
||||||
|
case CONNECTION_ERROR:
|
||||||
|
failureReason = R.drawable.signal_none;
|
||||||
|
break;
|
||||||
|
case HTTP_ERROR:
|
||||||
|
case LOCAL_ERROR:
|
||||||
|
case UNKNOWN_ERROR:
|
||||||
|
case PROTOCOL_ERROR:
|
||||||
|
failureReason = R.drawable.signal_fail;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
failureReason = R.drawable.signal_fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
mSignalIndicator.setImageResource(failureReason);
|
||||||
|
mSignalIndicator.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogMessageType {
|
||||||
|
MEASUREMENT_FAILURE,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onMeasurementFailure(Measurement measurement) {
|
||||||
|
|
||||||
|
updateMeasurement(measurement);
|
||||||
|
|
||||||
|
int numFailedBefore = mDataManager.getNumFailedMeasurements();
|
||||||
|
mDataManager.logMessage(measurement.appId, MEASUREMENT_FAILURE.toString(), "measurement failure. numFailedBefore=" + numFailedBefore);
|
||||||
|
|
||||||
|
mDataManager.addFailedMeasurement();
|
||||||
|
|
||||||
|
startAnimation();
|
||||||
|
|
||||||
|
mDataManager.logFailure(measurement, new IMeasurementDataManager.MeasurementResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(Measurement measurement) {
|
||||||
|
// show result from server (UNCLASSIFIED)
|
||||||
|
Intent intent = new Intent(BreatheActivity.this, ResultActivity.class);
|
||||||
|
intent.putExtra("measurementId", measurement.getId());
|
||||||
|
startActivity(intent);
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
onCreateError(measurement, code, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateMeasurement(Measurement measurement) {
|
||||||
|
measurement.user = mUser;
|
||||||
|
measurement.app = mAppInfo.getAppMeta();
|
||||||
|
|
||||||
|
measurement.meta.mode = mMode;
|
||||||
|
measurement.meta.test = TEST_MODE;
|
||||||
|
|
||||||
|
// if dummy microphone, pretend we're already synced
|
||||||
|
if(measurement.series.getAudio().length == 0)
|
||||||
|
measurement.series.audioSynced = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
|
||||||
|
try {
|
||||||
|
mController.stop();
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
// ignored, since result is discarded here anyways
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAnimation();
|
||||||
|
stopBackgroundThread();
|
||||||
|
|
||||||
|
mDataManager.stopBackgroundThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two layers of signal quality checks:
|
||||||
|
*
|
||||||
|
* 1. finger presence: (redness, uniformity, limit brightness-swings)
|
||||||
|
* * restarts if you take off your finger
|
||||||
|
* vibrates on restarts
|
||||||
|
* * 30s timer limit if never locked for at least 10s
|
||||||
|
* TODO: BUG: if never locked, timeSinceLock keeps increasing.
|
||||||
|
*
|
||||||
|
* 2. pulse beat quality: (beat detection and cross-correlation of beats)
|
||||||
|
* * locks if 2+ similar beats are found
|
||||||
|
* * only shows BPM if the quality is locked
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
// f = 0.3 - (0.3 - 0.08) * t/T
|
||||||
|
// plt.plot(t, np.sin(np.cumsum(2*np.pi*f/fps)))
|
||||||
|
|
||||||
|
double mPrevArea = 0.0;
|
||||||
|
double mLastVibrate = 0.0;
|
||||||
|
Vibrator mV;
|
||||||
|
void updateCircle(double timeSinceLock) {
|
||||||
|
final double F_START = 0.22;
|
||||||
|
final double F_END = 0.05;
|
||||||
|
|
||||||
|
//double f = F_START + (F_END - F_START) * timeSinceLock / mTotalTime;
|
||||||
|
|
||||||
|
// # int_{t=0}^{t=T} k * (c + d * t) = [kct + kd t^2/2]_{t=T}
|
||||||
|
// plt.plot(t, np.sin(k*c*t + k*d*t**2/2))
|
||||||
|
|
||||||
|
double T = mTotalTime;
|
||||||
|
double t = timeSinceLock;
|
||||||
|
|
||||||
|
double k = 2*Math.PI;
|
||||||
|
double c = F_START;
|
||||||
|
double d = (F_END - F_START) / T;
|
||||||
|
|
||||||
|
double omega = k*c*t + k*d*t*t/2.0;
|
||||||
|
|
||||||
|
// match relative area of circle to (0.5 + 0.5 * sin(omega))
|
||||||
|
double area = 0.5 + 0.5 * Math.sin(omega);
|
||||||
|
double darea = area - mPrevArea;
|
||||||
|
mPrevArea = area;
|
||||||
|
|
||||||
|
boolean finished = timeSinceLock > mTotalTime;
|
||||||
|
|
||||||
|
if(finished) {
|
||||||
|
mTitleText.setText("Fertig. Danke!");
|
||||||
|
mCircleView.setDiameter(1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//mTitleText.setText((darea > 0.0) ? "Einatmen" : "Ausatmen");
|
||||||
|
double dt = 0.2;
|
||||||
|
mTitleText.setText((Math.cos(k*c*(t+dt) + k*d*(t+dt)*(t+dt)/2.0) > 0.0) ? "Einatmen" : "Ausatmen"); // 0.2 sec earlier, as timing assistance
|
||||||
|
mCircleView.setDiameter(area*area); // nice and smooth for small circles
|
||||||
|
|
||||||
|
/*
|
||||||
|
double clipSize = Math.cos(omega); // + Math.PI / 2.0
|
||||||
|
clipSize = 0.5 + 0.5 * Math.max(clipSize, 0.0);
|
||||||
|
|
||||||
|
if(timeSinceLock - mLastVibrate > (1.0 - clipSize) * 0.1) {
|
||||||
|
//v.vibrate(((long) (100.0 * clipSize)));
|
||||||
|
mV.vibrate(50);
|
||||||
|
|
||||||
|
mLastVibrate = timeSinceLock;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
private RunningQuality mQuality;
|
||||||
|
|
||||||
|
/** filter and plot the red channel of the camera */
|
||||||
|
private IMeasurementController.Listener mListener = new IMeasurementController.Listener() {
|
||||||
|
private int mI;
|
||||||
|
private IMeasurementController.State mPrevState = IMeasurementController.State.STOPPED;
|
||||||
|
private double mTimeSinceLock = 0.0;
|
||||||
|
private boolean mEverLocked = false;
|
||||||
|
|
||||||
|
private void clear() {
|
||||||
|
mI = 0;
|
||||||
|
mPrevSigTs = 0.0;
|
||||||
|
mTimeSinceLock = 0.0;
|
||||||
|
efBuzzed1 = false;
|
||||||
|
efBuzzed2 = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onState(IMeasurementController.State newState) {
|
||||||
|
if(newState == IMeasurementController.State.STOPPED || newState == IMeasurementController.State.FAILED)
|
||||||
|
clear();
|
||||||
|
|
||||||
|
if(newState == IMeasurementController.State.FAILED) {
|
||||||
|
if(mController.getFailureReason().equals(IMeasurementController.FailureReason.CAMERA_FRAMES_DROPPED)) {
|
||||||
|
startErrorActivity(getString(R.string.frames_dropped));
|
||||||
|
} else {
|
||||||
|
startErrorActivity("Error: Measurement failed: " + mController.getFailureReason().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(newState == IMeasurementController.State.SAMPLING) {
|
||||||
|
if(!mEverLocked) {
|
||||||
|
// short vibrate at first lock
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(200);
|
||||||
|
}
|
||||||
|
mEverLocked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mPrevState == IMeasurementController.State.SAMPLING && newState == IMeasurementController.State.WAITING) {
|
||||||
|
// avoid message when user puts their finger on (bouncing camera lock)
|
||||||
|
if(mTimeSinceLock > 5.0) {
|
||||||
|
Toast.makeText(BreatheActivity.this, "Measurement restarted", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
mQuality.clear();
|
||||||
|
// note: both SQI and swing-level restarts go through here
|
||||||
|
|
||||||
|
// vibrate on restart
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
mPrevState = newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double mPrevSigTs = 0.0;
|
||||||
|
|
||||||
|
/** beatdetect() for SQI and BPM will run on the signal starting from (cameraLockTime + SQI_SETTLING_TIME) */
|
||||||
|
private static final double SQI_SETTLING_TIME = 3.0;
|
||||||
|
|
||||||
|
private boolean efBuzzed1 = false;
|
||||||
|
private boolean efBuzzed2 = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrame(final double timeSinceStart, final double timeSinceLock, double[] rgb, IntensityDetector.ImageSummary summary) {
|
||||||
|
final int RED_CHANNEL = 0;
|
||||||
|
final double entry = rgb[RED_CHANNEL];
|
||||||
|
|
||||||
|
mTimeSinceLock = timeSinceLock;
|
||||||
|
|
||||||
|
BreatheActivity.this.runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
updateCircle(timeSinceLock);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(++mI % 30 == 0) {
|
||||||
|
mCountdownText.setText(Integer.toString(((int) (mTotalTime - timeSinceLock))));
|
||||||
|
|
||||||
|
int progSeqId = (int) (mProgSequence.length() * timeSinceLock / mTotalTime);
|
||||||
|
progSeqId = Math.min(progSeqId, mProgSequence.length()-1);
|
||||||
|
mProgressCircle.setImageResource(mProgSequence.getResourceId(progSeqId, -1));
|
||||||
|
mProgressCircle.invalidate();
|
||||||
|
|
||||||
|
// when pressing Back, it happens that mBackgroundHandler is null here - onPause() called before running this callback Runnable
|
||||||
|
// we could check if Activity is paused before dispatching in MeasurementController, but then we'd need to hand the Activity to it.
|
||||||
|
if(mBackgroundHandler != null) {
|
||||||
|
|
||||||
|
if(mQuality.isRunning()) {
|
||||||
|
mBackgroundHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// takes some time, at longer signals (since it does full beat detection)
|
||||||
|
// run it in the background to avoid UI lag of the graph plotting
|
||||||
|
updateHeartRate(mController.getLockTime() + SQI_SETTLING_TIME);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// running signal quality
|
||||||
|
//
|
||||||
|
|
||||||
|
if(mPrevState == IMeasurementController.State.SAMPLING && timeSinceLock > SQI_SETTLING_TIME) {
|
||||||
|
mBackgroundHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// use signal since lock, otherwise the amplitude in Signal.beatdetect() takes too long to settle
|
||||||
|
if(mPrevSigTs == 0.0)
|
||||||
|
mPrevSigTs = mController.getLockTime() + SQI_SETTLING_TIME;
|
||||||
|
|
||||||
|
// must use detrended data, since the DC would mean the correlations will look too good to be true.
|
||||||
|
PlotFilter.Series ser = getSeriesSinceLock(mPrevSigTs);
|
||||||
|
|
||||||
|
if(ser.t.length > 0) {
|
||||||
|
mQuality.append(ser.t[0], ser.x);
|
||||||
|
Log.i(TAG, "mQuality.append() of pts.length=" + ser.x.length);
|
||||||
|
|
||||||
|
mPrevSigTs = ser.t[ser.t.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: if the camera was never locked (unlikely), `timeSinceLock` reports same as `timeSinceStart`
|
||||||
|
|
||||||
|
//boolean lockOverdue = timeSinceLock > REQUIRE_LOCK_BY_TIME; // camera lock <--- long time ---> ?
|
||||||
|
|
||||||
|
boolean lockOverdue = false; // disabled for doctors - always finish the measurement.
|
||||||
|
|
||||||
|
if(lockOverdue && !mQuality.isRunning()) {
|
||||||
|
try {
|
||||||
|
mController.stop();
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
startErrorActivity(getString(R.string.frames_dropped));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMeasurementFailure(mController.getMeasurement());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bad: since more than 30 secs, there was no lock of over 10 sec duration?
|
||||||
|
boolean finished = timeSinceLock > mTotalTime;
|
||||||
|
boolean notLockedTrouble = (timeSinceStart > 30.0 && timeSinceLock < 10.0); // && (mMode.equals(IMeasurementController.MeasurementMode.RISK) || (mMode.equals(IMeasurementController.MeasurementMode.ENDOTHELIAL_FUNCTION) && timeSinceStart < TOTAL_TIME_RISK))
|
||||||
|
|
||||||
|
if(mMode.equals(IMeasurementController.MeasurementMode.ENDOTHELIAL_FUNCTION) && timeSinceLock > TOTAL_TIME_RISK && !efBuzzed1) {
|
||||||
|
// buzz 1: inflate cuff (provoke arm ischemia)
|
||||||
|
Toast.makeText(BreatheActivity.this, "Inflate cuff now", Toast.LENGTH_LONG).show();
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(200);
|
||||||
|
efBuzzed1 = true;
|
||||||
|
} else if(mMode.equals(IMeasurementController.MeasurementMode.ENDOTHELIAL_FUNCTION) && timeSinceLock > (TOTAL_TIME_ENDOTHELIAL_FUNCTION - TOTAL_TIME_RISK) && !efBuzzed2) {
|
||||||
|
// buzz 2: deflate cuff (remove arm ischemia)
|
||||||
|
Toast.makeText(BreatheActivity.this, "Deflate cuff now", Toast.LENGTH_LONG).show();
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(200);
|
||||||
|
efBuzzed2 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(finished || notLockedTrouble) {
|
||||||
|
// ensure we only call stop() once
|
||||||
|
if(mController.getState() == IMeasurementController.State.STOPPED)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.i(TAG, "since more than 30 secs, there was no lock of over 10 sec duration. timeSinceLock=" + timeSinceLock + " timeSinceStart=" + timeSinceStart);
|
||||||
|
mController.stop();
|
||||||
|
|
||||||
|
if(mQuality.isRunning() && mNumNoisy <= MAX_NUM_NOISY) {
|
||||||
|
// what if never locked properly? wrong API endpoint?
|
||||||
|
onMeasurementFinished(mController.getMeasurement());
|
||||||
|
} else {
|
||||||
|
// long vibrate on fail
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(new long[]{400, 400, 400, 400}, -1);
|
||||||
|
|
||||||
|
// codepath used for doctors
|
||||||
|
onMeasurementFailure(mController.getMeasurement());
|
||||||
|
}
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
startErrorActivity(getString(R.string.frames_dropped));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Runnable runnable = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mPlotFilter.addUneven(timeSinceStart, entry);
|
||||||
|
PlotFilter.Series slice = mPlotFilter.getPlotSeries();
|
||||||
|
mChart.replaceData(slice.t, slice.x);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runOnUiThread(runnable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private void startOtherActivity(Class<?> activityClass) {
|
||||||
|
Intent intent = new Intent(BreatheActivity.this, activityClass);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
startActivity(intent);
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startErrorActivity(String message) {
|
||||||
|
mDataManager.logMessage(mAppInfo.getAppMeta().appId, ERROR.toString(), message);
|
||||||
|
|
||||||
|
Intent intent = new Intent(BreatheActivity.this, ErrorActivity.class);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
intent.putExtra("message", message);
|
||||||
|
startActivity(intent);
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlotFilter.Series getSeriesSinceLock(double lockTime) {
|
||||||
|
int len = mPlotFilter.size();
|
||||||
|
PlotFilter.Series series = mPlotFilter.getRecentSeries(len);
|
||||||
|
|
||||||
|
int istart = 0;
|
||||||
|
for(; istart < series.t.length; istart++)
|
||||||
|
if(series.t[istart] > lockTime)
|
||||||
|
break;
|
||||||
|
|
||||||
|
double[] x = DVec.get(series.x, istart, series.x.length);
|
||||||
|
double[] t = DVec.get(series.t, istart, series.t.length);
|
||||||
|
return new PlotFilter.Series(t, x);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateHeartRate(double sinceTime) {
|
||||||
|
int len = mPlotFilter.size();
|
||||||
|
|
||||||
|
// ignore the initial, large swing
|
||||||
|
PlotFilter.Series ser = getSeriesSinceLock(sinceTime);
|
||||||
|
double[] pts = ser.x;
|
||||||
|
|
||||||
|
// we should make sure that detrended amplitude swing is much smaller (< e.g. 16, see ipynb exploration) in recent stuff
|
||||||
|
|
||||||
|
Log.i(TAG, "updateHeartRate() len=" + len + " pointsSinceLock.length=" + pts.length);
|
||||||
|
|
||||||
|
final Double bpm = Signal.calculateBpm(FPS, pts);
|
||||||
|
|
||||||
|
if(pts.length >= 60) {
|
||||||
|
double[] smoothd = Signal.running_mean(Signal.running_mean(pts, 6), 7);
|
||||||
|
double[] slice = DVec.get(smoothd, smoothd.length - 30, smoothd.length);
|
||||||
|
Log.i(TAG, "swing amplitude: " + (DVec.max(slice) - DVec.min(slice)));
|
||||||
|
}
|
||||||
|
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if(bpm != null)
|
||||||
|
mHeartRateText.setText(Integer.toString(bpm.intValue()));
|
||||||
|
else
|
||||||
|
mHeartRateText.setText("- ");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startBackgroundThread() {
|
||||||
|
mBackgroundThread = new HandlerThread("MeasureBackground");
|
||||||
|
mBackgroundThread.start();
|
||||||
|
mBackgroundHandler = new Handler(mBackgroundThread.getLooper());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopBackgroundThread() {
|
||||||
|
if(mBackgroundThread != null) {
|
||||||
|
mBackgroundThread.quitSafely();
|
||||||
|
try {
|
||||||
|
mBackgroundThread.join();
|
||||||
|
mBackgroundThread = null;
|
||||||
|
mBackgroundHandler = null;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import net.heartshield.sensors.IntensityDetector;
|
||||||
|
import net.heartshield.sensors.SensorFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-14
|
||||||
|
*/
|
||||||
|
public class CameraTestActivity extends Activity {
|
||||||
|
private HandlerThread mBackgroundThread;
|
||||||
|
private Handler mBackgroundHandler;
|
||||||
|
private Runnable mTask;
|
||||||
|
private IntensityDetector mIntensitySensor;
|
||||||
|
private TextView mText;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_camera_test);
|
||||||
|
|
||||||
|
mText = (TextView) findViewById(R.id.textView);
|
||||||
|
mIntensitySensor = new SensorFactory(this).getIntensityDetector(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
startBackgroundThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
stopBackgroundThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postText(final String text) {
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mText.setText(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean started = false;
|
||||||
|
|
||||||
|
public void start(View view) {
|
||||||
|
if(started) {
|
||||||
|
postText("already started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
started = true;
|
||||||
|
postText("mIntensitySensor.start()");
|
||||||
|
|
||||||
|
mIntensitySensor.setDebugMode(true);
|
||||||
|
mIntensitySensor.start();
|
||||||
|
|
||||||
|
mTask = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
myRun();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void myRun() throws InterruptedException {
|
||||||
|
Thread.sleep(3000);
|
||||||
|
postText("mIntensitySensor.lock()");
|
||||||
|
mIntensitySensor.lock();
|
||||||
|
|
||||||
|
Thread.sleep(3000);
|
||||||
|
postText("mIntensitySensor.stop()");
|
||||||
|
mIntensitySensor.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mBackgroundHandler.post(mTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop(View view) {
|
||||||
|
postText("mIntensitySensor.stop()");
|
||||||
|
mIntensitySensor.stop();
|
||||||
|
mBackgroundThread.interrupt();
|
||||||
|
stopBackgroundThread();
|
||||||
|
startBackgroundThread();
|
||||||
|
started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startBackgroundThread() {
|
||||||
|
mBackgroundThread = new HandlerThread("MeasureBackground");
|
||||||
|
mBackgroundThread.start();
|
||||||
|
mBackgroundHandler = new Handler(mBackgroundThread.getLooper());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopBackgroundThread() {
|
||||||
|
if(mBackgroundThread != null) {
|
||||||
|
mBackgroundThread.quitSafely();
|
||||||
|
try {
|
||||||
|
mBackgroundThread.join();
|
||||||
|
mBackgroundThread = null;
|
||||||
|
mBackgroundHandler = null;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.media.MediaPlayer;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.CheckBox;
|
||||||
|
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-14
|
||||||
|
*/
|
||||||
|
public class DevSettingsActivity extends Activity {
|
||||||
|
private AppInfo mAppInfo;
|
||||||
|
private MediaPlayer mPlayer;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_dev_settings);
|
||||||
|
|
||||||
|
mAppInfo = AppInfo.getInstance(this);
|
||||||
|
|
||||||
|
mPlayer = MediaPlayer.create(this, R.raw.beep);
|
||||||
|
|
||||||
|
// load DevSettings
|
||||||
|
AppInfo.DevSettings devSettings = mAppInfo.devSettings;
|
||||||
|
((CheckBox) findViewById(R.id.useTestServer)).setChecked(devSettings.useTestServer);
|
||||||
|
((CheckBox) findViewById(R.id.useMockData)).setChecked(devSettings.useMockData);
|
||||||
|
((CheckBox) findViewById(R.id.skipDialogs)).setChecked(devSettings.skipDialogs);
|
||||||
|
((CheckBox) findViewById(R.id.showPersonalTracking)).setChecked(devSettings.showPersonalTracking);
|
||||||
|
((CheckBox) findViewById(R.id.showHeightDialog)).setChecked(devSettings.showHeightDialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(View view) {
|
||||||
|
// set DevSettings
|
||||||
|
AppInfo.DevSettings devSettings = mAppInfo.devSettings;
|
||||||
|
devSettings.useTestServer = ((CheckBox) findViewById(R.id.useTestServer)).isChecked();
|
||||||
|
devSettings.useMockData = ((CheckBox) findViewById(R.id.useMockData)).isChecked();
|
||||||
|
devSettings.skipDialogs = ((CheckBox) findViewById(R.id.skipDialogs)).isChecked();
|
||||||
|
devSettings.showPersonalTracking = ((CheckBox) findViewById(R.id.showPersonalTracking)).isChecked();
|
||||||
|
devSettings.showHeightDialog = ((CheckBox) findViewById(R.id.showHeightDialog)).isChecked();
|
||||||
|
mAppInfo.saveDevSettings(this);
|
||||||
|
|
||||||
|
// return to Home
|
||||||
|
Intent intent = new Intent(DevSettingsActivity.this, HomeActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
public void cancel(View view) {
|
||||||
|
Intent intent = new Intent(DevSettingsActivity.this, HomeActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cameraTest(View view) {
|
||||||
|
Intent intent = new Intent(DevSettingsActivity.this, CameraTestActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void beep(View view) {
|
||||||
|
mPlayer.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.support.annotation.BoolRes;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.text.method.LinkMovementMethod;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
import net.heartshield.data.DataManagerFactory;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.MeasurementDataManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-24
|
||||||
|
*/
|
||||||
|
public class DisclaimerActivity extends Activity {
|
||||||
|
public static final String TAG = "DisclaimerActivity";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_disclaimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
TextView activationText = (TextView) findViewById(R.id.activationText);
|
||||||
|
activationText.setMovementMethod(LinkMovementMethod.getInstance());
|
||||||
|
|
||||||
|
boolean forceShow = getIntent().getBooleanExtra("forceShow", false);
|
||||||
|
AppInfo appInfo = AppInfo.getInstance(this);
|
||||||
|
|
||||||
|
boolean isDebugBuild = BuildConfig.BUILD_TYPE.equals("debug");
|
||||||
|
boolean show = (appInfo.isFirstInstall() || !appInfo.isActivated() || forceShow) && !isDebugBuild;
|
||||||
|
|
||||||
|
Log.i(TAG, "appInfo.isFirstInstall()=" + Boolean.toString(appInfo.isFirstInstall()) + " || !appInfo.isActivated()=" + Boolean.toString(!appInfo.isActivated()) + " || forceShow=" + Boolean.toString(forceShow) + ";");
|
||||||
|
|
||||||
|
View activation = findViewById(R.id.activation);
|
||||||
|
activation.setVisibility(appInfo.isActivated() ? View.GONE : View.VISIBLE);
|
||||||
|
|
||||||
|
if(isDebugBuild && appInfo.getAppMeta().appId.equals("")) {
|
||||||
|
// apparently happens at reinstall
|
||||||
|
|
||||||
|
// activate using a dummy ID
|
||||||
|
appInfo.activate("DEADBEEF");
|
||||||
|
// immediately show home screen
|
||||||
|
showHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!appInfo.isFirstInstall() && !appInfo.isActivated()) {
|
||||||
|
selfActivate(appInfo.getAppMeta().appId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!show)
|
||||||
|
showHome();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selfActivate(String activationCode) {
|
||||||
|
// assume that user got an update which now forces activation - activate them with their existing appId
|
||||||
|
EditText activationCodeTxt = (EditText) findViewById(R.id.activationCode);
|
||||||
|
activationCodeTxt.setText(activationCode);
|
||||||
|
|
||||||
|
// send activation to server (goes to background)
|
||||||
|
accept(null);
|
||||||
|
|
||||||
|
// immediately show home screen
|
||||||
|
showHome();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void accept(View view) {
|
||||||
|
final AppInfo appInfo = AppInfo.getInstance(this);
|
||||||
|
|
||||||
|
if(!appInfo.isActivated()) {
|
||||||
|
EditText activationCodeTxt = (EditText) findViewById(R.id.activationCode);
|
||||||
|
String activationCode = activationCodeTxt.getText().toString().toUpperCase();
|
||||||
|
if(activationCode.equals("")) {
|
||||||
|
Toast.makeText(DisclaimerActivity.this, "Need an activation code to proceed.", Toast.LENGTH_LONG).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IMeasurementDataManager mdm = DataManagerFactory.getInstance().getDataManager(getApplicationContext());
|
||||||
|
mdm.activate(activationCode, new IMeasurementDataManager.ActivationResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(String appId) {
|
||||||
|
appInfo.activate(appId);
|
||||||
|
Toast.makeText(DisclaimerActivity.this, "Activation successful.", Toast.LENGTH_LONG).show();
|
||||||
|
|
||||||
|
showHome();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String reason) {
|
||||||
|
Toast.makeText(DisclaimerActivity.this, "Activation failed: " + reason, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showHome() {
|
||||||
|
Intent intent = new Intent(DisclaimerActivity.this, HomeActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void exit(View view) {
|
||||||
|
moveTaskToBack(true);
|
||||||
|
android.os.Process.killProcess(android.os.Process.myPid());
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void licenses(View view) {
|
||||||
|
Intent intent = new Intent(DisclaimerActivity.this, LicenseInfoActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
307
app/src/main/java/net/heartshield/prevent/EditActivity.java
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.text.format.DateFormat;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.inputmethod.EditorInfo;
|
||||||
|
import android.view.inputmethod.InputMethodManager;
|
||||||
|
import android.widget.CheckBox;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.SeekBar;
|
||||||
|
import android.widget.TableLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
import net.heartshield.data.DataManagerFactory;
|
||||||
|
import net.heartshield.data.DoctorDetails;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.Measurement;
|
||||||
|
import net.heartshield.data.MeasurementId;
|
||||||
|
import net.heartshield.data.MeasurementResult;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-06-05
|
||||||
|
*/
|
||||||
|
public class EditActivity extends Activity {
|
||||||
|
private static final String TAG = "EditActivity";
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private Measurement mMeasurement;
|
||||||
|
private AppInfo mAppInfo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_edit);
|
||||||
|
|
||||||
|
mDateFormat = DateFormat.getDateFormat(this);
|
||||||
|
mTimeFormat = DateFormat.getTimeFormat(this);
|
||||||
|
|
||||||
|
int[] defocusableEditTexts = new int[]{R.id.chol_total, R.id.other_diag, R.id.other_med};
|
||||||
|
for(int id : defocusableEditTexts) {
|
||||||
|
EditText editText = (EditText) findViewById(id);
|
||||||
|
editText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
|
||||||
|
@Override
|
||||||
|
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
|
||||||
|
if(actionId == EditorInfo.IME_ACTION_DONE) {
|
||||||
|
|
||||||
|
// hide soft keyboard
|
||||||
|
InputMethodManager inputManager =
|
||||||
|
(InputMethodManager) EditActivity.this.
|
||||||
|
getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||||
|
inputManager.hideSoftInputFromWindow(
|
||||||
|
EditActivity.this.getCurrentFocus().getWindowToken(),
|
||||||
|
InputMethodManager.HIDE_NOT_ALWAYS);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean mShowPersonalTrackingInfo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
mAppInfo = AppInfo.getInstance(this);
|
||||||
|
mShowPersonalTrackingInfo = mAppInfo.devSettings.showPersonalTracking;
|
||||||
|
|
||||||
|
mDataManager = DataManagerFactory.getInstance().getDataManager(getApplicationContext());
|
||||||
|
mDataManager.startBackgroundThread();
|
||||||
|
mDataManager.startBackgroundSync();
|
||||||
|
|
||||||
|
MeasurementId id = (MeasurementId) getIntent().getSerializableExtra("measurementId");
|
||||||
|
Measurement measurement;
|
||||||
|
try {
|
||||||
|
measurement = mDataManager.retrieve(id);
|
||||||
|
mMeasurement = measurement;
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
Toast.makeText(EditActivity.this, "Local filesystem failure: " + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
|
||||||
|
// go back to Home screen
|
||||||
|
Intent intent = new Intent(EditActivity.this, HomeActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
mMeasurement = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMeasurement(measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
mDataManager.stopBackgroundThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
super.onBackPressed();
|
||||||
|
save(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(View view) {
|
||||||
|
updateMeasurement(mMeasurement);
|
||||||
|
|
||||||
|
mDataManager.update(mMeasurement, new IMeasurementDataManager.MeasurementResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(Measurement measurement) {
|
||||||
|
Toast.makeText(EditActivity.this, "Measurement saved.", Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
// it's still marked dirty, and will be transmitted next time
|
||||||
|
Toast.makeText(EditActivity.this, "Failed to save edit.", Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void discard(View view) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Element {
|
||||||
|
public String name;
|
||||||
|
public int id;
|
||||||
|
|
||||||
|
Element(String name, int id) {
|
||||||
|
this.name = name;
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Element[] CHECKBOXES = {
|
||||||
|
new Element("smoker", R.id.smoker),
|
||||||
|
new Element("diabetes", R.id.diabetes),
|
||||||
|
new Element("hypertension", R.id.hypertension),
|
||||||
|
new Element("cvd", R.id.cvd),
|
||||||
|
new Element("no_cvd", R.id.no_cvd),
|
||||||
|
new Element("no_afib", R.id.no_afib),
|
||||||
|
new Element("afib_sin", R.id.afib_sin),
|
||||||
|
new Element("afib_now", R.id.afib_now),
|
||||||
|
new Element("no_cad", R.id.no_cad),
|
||||||
|
new Element("cad_lt70", R.id.cad_lt70),
|
||||||
|
new Element("cad_gt70", R.id.cad_gt70),
|
||||||
|
new Element("betablocker", R.id.betablocker),
|
||||||
|
new Element("antiarrhythmic", R.id.antiarrhythmic),
|
||||||
|
new Element("antihypertensive", R.id.antihypertensive)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final Element[] TEXTBOXES = {
|
||||||
|
new Element("bp_sys", R.id.bp_sys),
|
||||||
|
new Element("bp_dia", R.id.bp_dia),
|
||||||
|
new Element("chol_hdl", R.id.chol_hdl),
|
||||||
|
new Element("chol_total", R.id.chol_total),
|
||||||
|
new Element("other_med", R.id.other_med),
|
||||||
|
new Element("other_diag", R.id.other_diag),
|
||||||
|
};
|
||||||
|
|
||||||
|
private java.text.DateFormat mDateFormat;
|
||||||
|
private java.text.DateFormat mTimeFormat;
|
||||||
|
|
||||||
|
private static int getInt(Integer i) {
|
||||||
|
if(i == null)
|
||||||
|
return 0;
|
||||||
|
else
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Measurement -> UI */
|
||||||
|
private void showMeasurement(Measurement measurement) {
|
||||||
|
if(measurement.doctorDetails == null)
|
||||||
|
measurement.doctorDetails = new DoctorDetails();
|
||||||
|
|
||||||
|
// label elements
|
||||||
|
showLabelElements(measurement);
|
||||||
|
|
||||||
|
View personalTrackingInfo = findViewById(R.id.personalTrackingInfo);
|
||||||
|
personalTrackingInfo.setVisibility(mShowPersonalTrackingInfo ? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
|
if(mShowPersonalTrackingInfo) {
|
||||||
|
SeekBar seekHappiness = (SeekBar) findViewById(R.id.seekHappiness);
|
||||||
|
SeekBar seekSleepQuality = (SeekBar) findViewById(R.id.seekSleepQuality);
|
||||||
|
seekHappiness.setProgress(getInt(measurement.doctorDetails.happiness));
|
||||||
|
seekSleepQuality.setProgress(getInt(measurement.doctorDetails.sleep_quality));
|
||||||
|
}
|
||||||
|
|
||||||
|
// form elements
|
||||||
|
try {
|
||||||
|
details2ui(new JSONObject(measurement.doctorDetails.serialize()));
|
||||||
|
} catch (JSONException e) {
|
||||||
|
Toast.makeText(EditActivity.this, "Error reading doctorDetails, this should never happen", Toast.LENGTH_LONG).show();
|
||||||
|
// this should never happen
|
||||||
|
// TODO: proper logging to server
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLabelElements(Measurement measurement) {
|
||||||
|
MeasurementResult result = measurement.result;
|
||||||
|
|
||||||
|
TextView date = (TextView) findViewById(R.id.date);
|
||||||
|
TextView measurementId = (TextView) findViewById(R.id.measurementId);
|
||||||
|
TextView heartRate = (TextView) findViewById(R.id.heartRate);
|
||||||
|
TextView heartScore = (TextView) findViewById(R.id.heartScore);
|
||||||
|
TextView age = (TextView) findViewById(R.id.age);
|
||||||
|
TextView gender = (TextView) findViewById(R.id.gender);
|
||||||
|
|
||||||
|
Date timeStamp = new java.util.Date(measurement.startTime * 1000);
|
||||||
|
String dateText = mDateFormat.format(timeStamp) + " at " + mTimeFormat.format(timeStamp);
|
||||||
|
date.setText(dateText);
|
||||||
|
|
||||||
|
String midText = "?";
|
||||||
|
if(result != null && result.token != null)
|
||||||
|
midText = result.token;
|
||||||
|
measurementId.setText(midText);
|
||||||
|
|
||||||
|
if(result != null)
|
||||||
|
heartRate.setText(Integer.toString((int) result.getHeartRate()) + " bpm");
|
||||||
|
else
|
||||||
|
heartRate.setText("-");
|
||||||
|
|
||||||
|
if(result != null && result.status.equals(MeasurementResult.Status.OK))
|
||||||
|
heartScore.setText(Integer.toString((int) (100.0 * result.pred)));
|
||||||
|
else
|
||||||
|
heartScore.setText("?");
|
||||||
|
|
||||||
|
age.setText(Integer.toString(measurement.user.age));
|
||||||
|
gender.setText(measurement.user.gender.toString().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI -> Measurement */
|
||||||
|
private void updateMeasurement(Measurement measurement) {
|
||||||
|
try {
|
||||||
|
JSONObject details = ui2details();
|
||||||
|
measurement.doctorDetails = DoctorDetails.deserialize(details.toString());
|
||||||
|
measurement.doctorDetails.time = System.currentTimeMillis() / 1000;
|
||||||
|
} catch (JSONException e) {
|
||||||
|
Toast.makeText(EditActivity.this, "Error writing doctorDetails, this should never happen", Toast.LENGTH_LONG).show();
|
||||||
|
// this should never happen
|
||||||
|
// TODO: proper logging to server
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mShowPersonalTrackingInfo) {
|
||||||
|
SeekBar seekHappiness = (SeekBar) findViewById(R.id.seekHappiness);
|
||||||
|
SeekBar seekSleepQuality = (SeekBar) findViewById(R.id.seekSleepQuality);
|
||||||
|
measurement.doctorDetails.happiness = seekHappiness.getProgress();
|
||||||
|
measurement.doctorDetails.sleep_quality = seekSleepQuality.getProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject ui2details() throws JSONException {
|
||||||
|
|
||||||
|
JSONObject details = new JSONObject();
|
||||||
|
|
||||||
|
for(Element chk : CHECKBOXES) {
|
||||||
|
CheckBox checkBox = (CheckBox) findViewById(chk.id);
|
||||||
|
details.put(chk.name, checkBox.isChecked());
|
||||||
|
}
|
||||||
|
|
||||||
|
for(Element txt : TEXTBOXES) {
|
||||||
|
EditText editText = (EditText) findViewById(txt.id);
|
||||||
|
details.put(txt.name, editText.getText());
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void details2ui(JSONObject details) throws JSONException {
|
||||||
|
for(Element chk : CHECKBOXES) {
|
||||||
|
if(!details.has(chk.name))
|
||||||
|
continue;
|
||||||
|
CheckBox checkBox = (CheckBox) findViewById(chk.id);
|
||||||
|
checkBox.setChecked(details.getBoolean(chk.name));
|
||||||
|
checkBox.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
for(Element txt : TEXTBOXES) {
|
||||||
|
if(!details.has(txt.name))
|
||||||
|
continue;
|
||||||
|
EditText editText = (EditText) findViewById(txt.id);
|
||||||
|
editText.setText(details.getString(txt.name));
|
||||||
|
editText.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/src/main/java/net/heartshield/prevent/EnterAgeActivity.java
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.MeasurementDataManager;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
|
||||||
|
import java.util.Timer;
|
||||||
|
import java.util.TimerTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter age and gender before measurement.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-01
|
||||||
|
*/
|
||||||
|
public class EnterAgeActivity extends Activity {
|
||||||
|
private static final String TAG = "EnterAgeActivity";
|
||||||
|
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private Timer mConnTimer;
|
||||||
|
|
||||||
|
private com.shawnlin.numberpicker.NumberPicker mAgePicker;
|
||||||
|
private ImageView mConnection;
|
||||||
|
private IMeasurementController.MeasurementMode mMode;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_s01_enter_age);
|
||||||
|
|
||||||
|
mDataManager = MeasurementDataManager.getInstance(getApplicationContext());
|
||||||
|
|
||||||
|
mAgePicker = (com.shawnlin.numberpicker.NumberPicker) findViewById(R.id.age);
|
||||||
|
mConnection = (ImageView) findViewById(R.id.connection);
|
||||||
|
|
||||||
|
postConnImage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
startTimer();
|
||||||
|
|
||||||
|
mMode = (IMeasurementController.MeasurementMode) getIntent().getSerializableExtra("mode");
|
||||||
|
|
||||||
|
UserMeta user = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
if(user != null) {
|
||||||
|
Log.i(TAG, "got age=" + user.age + " gender=" + user.gender);
|
||||||
|
//mAgePicker.setValue(user.age);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int CONN_UPDATE_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
private void startTimer() {
|
||||||
|
if(mConnTimer == null) {
|
||||||
|
mConnTimer = new Timer();
|
||||||
|
mConnTimer.schedule(new TimerTask() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
updateConnectionDisplay();
|
||||||
|
}
|
||||||
|
}, CONN_UPDATE_INTERVAL_MS, CONN_UPDATE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopTimer() {
|
||||||
|
if(mConnTimer != null) {
|
||||||
|
mConnTimer.cancel();
|
||||||
|
mConnTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateConnectionDisplay() {
|
||||||
|
String appId = AppInfo.getInstance(this).getAppMeta().appId;
|
||||||
|
mDataManager.ping(appId, new IMeasurementDataManager.PingResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult() {
|
||||||
|
postConnImage(R.drawable.connection_ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError() {
|
||||||
|
postConnImage(R.drawable.connection_nok);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postConnImage(final int resId) {
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mConnection.setImageResource(resId);
|
||||||
|
mConnection.invalidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Enter medication (HRV influencing) before measurement
|
||||||
|
|
||||||
|
public void start(View view) {
|
||||||
|
//Intent intent = new Intent(EnterAgeActivity.this, TimeActivity.class);
|
||||||
|
|
||||||
|
Intent intent;
|
||||||
|
|
||||||
|
if(AppInfo.getInstance(this).devSettings.showHeightDialog) {
|
||||||
|
intent = new Intent(EnterAgeActivity.this, EnterHeightActivity.class);
|
||||||
|
} else {
|
||||||
|
intent = new Intent(EnterAgeActivity.this, EnterGenderActivity.class);
|
||||||
|
}
|
||||||
|
UserMeta meta = new UserMeta(mAgePicker.getValue(), null, null);
|
||||||
|
intent.putExtra("meta", meta);
|
||||||
|
intent.putExtra("mode", mMode);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stats(View view) {
|
||||||
|
Intent intent = new Intent(EnterAgeActivity.this, ResultListActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stetho(View view) {
|
||||||
|
Intent intent = new Intent(EnterAgeActivity.this, PcgActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void help(View view) {
|
||||||
|
Intent intent = new Intent(EnterAgeActivity.this, DisclaimerActivity.class);
|
||||||
|
intent.putExtra("forceShow", true);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.MeasurementDataManager;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
|
||||||
|
import java.util.Timer;
|
||||||
|
import java.util.TimerTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-06-08
|
||||||
|
*/
|
||||||
|
public class EnterGenderActivity extends Activity {
|
||||||
|
private static final String TAG = "EnterGenderActivity";
|
||||||
|
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private Timer mConnTimer;
|
||||||
|
|
||||||
|
private ToggleImageButton mMale;
|
||||||
|
private ToggleImageButton mFemale;
|
||||||
|
private FancyButton mStartButton;
|
||||||
|
private ImageView mConnection;
|
||||||
|
private UserMeta mUser;
|
||||||
|
private IMeasurementController.MeasurementMode mMode;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_s02_enter_gender);
|
||||||
|
|
||||||
|
mDataManager = MeasurementDataManager.getInstance(getApplicationContext());
|
||||||
|
|
||||||
|
mMale = (ToggleImageButton) findViewById(R.id.toggleMale);
|
||||||
|
mFemale = (ToggleImageButton) findViewById(R.id.toggleFemale);
|
||||||
|
mConnection = (ImageView) findViewById(R.id.connection);
|
||||||
|
mStartButton = (FancyButton) findViewById(R.id.startButton);
|
||||||
|
mStartButton.setEnabled(false);
|
||||||
|
|
||||||
|
postConnImage(0);
|
||||||
|
|
||||||
|
mMale.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
toggleGender(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mFemale.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
toggleGender(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
startTimer();
|
||||||
|
|
||||||
|
mMode = (IMeasurementController.MeasurementMode) getIntent().getSerializableExtra("mode");
|
||||||
|
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
if(mUser == null)
|
||||||
|
throw new IllegalStateException("expect to come from EnterAgeActivity together with age");
|
||||||
|
|
||||||
|
if(mUser.gender != null) {
|
||||||
|
// maybe we can guess the gender from before
|
||||||
|
ToggleImageButton button = (mUser.gender == UserMeta.Gender.MALE) ? mMale : mFemale;
|
||||||
|
button.setPressed(true);
|
||||||
|
toggleGender(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void toggleGender(View view) {
|
||||||
|
Log.i(TAG, "toggleGender(view=" + view + ") called");
|
||||||
|
ToggleImageButton button = (ToggleImageButton) view;
|
||||||
|
ToggleImageButton other = (button == mMale) ? mFemale : mMale;
|
||||||
|
if(button.getPressed())
|
||||||
|
other.setPressed(false);
|
||||||
|
|
||||||
|
mStartButton.setEnabled(mMale.getPressed() || mFemale.getPressed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int CONN_UPDATE_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
private void startTimer() {
|
||||||
|
if(mConnTimer == null) {
|
||||||
|
mConnTimer = new Timer();
|
||||||
|
mConnTimer.schedule(new TimerTask() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
updateConnectionDisplay();
|
||||||
|
}
|
||||||
|
}, CONN_UPDATE_INTERVAL_MS, CONN_UPDATE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopTimer() {
|
||||||
|
if(mConnTimer != null) {
|
||||||
|
mConnTimer.cancel();
|
||||||
|
mConnTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateConnectionDisplay() {
|
||||||
|
String appId = AppInfo.getInstance(this).getAppMeta().appId;
|
||||||
|
mDataManager.ping(appId, new IMeasurementDataManager.PingResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult() {
|
||||||
|
postConnImage(R.drawable.connection_ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError() {
|
||||||
|
postConnImage(R.drawable.connection_nok);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postConnImage(final int resId) {
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mConnection.setImageResource(resId);
|
||||||
|
mConnection.invalidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserMeta.Gender getGender() {
|
||||||
|
if(mMale.getPressed())
|
||||||
|
return UserMeta.Gender.MALE;
|
||||||
|
else if(mFemale.getPressed())
|
||||||
|
return UserMeta.Gender.FEMALE;
|
||||||
|
else
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(View view) {
|
||||||
|
final boolean breathing = mMode.equals(IMeasurementController.MeasurementMode.PACED_BREATHING) || mMode.equals(IMeasurementController.MeasurementMode.SWEEP_BREATHING);
|
||||||
|
Intent intent = new Intent(EnterGenderActivity.this, breathing ? BreatheActivity.class : MeasureActivity.class);
|
||||||
|
mUser.gender = getGender();
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
intent.putExtra("mode", mMode);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stats(View view) {
|
||||||
|
Intent intent = new Intent(EnterGenderActivity.this, ResultListActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stetho(View view) {
|
||||||
|
Intent intent = new Intent(EnterGenderActivity.this, PcgActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void help(View view) {
|
||||||
|
Intent intent = new Intent(EnterGenderActivity.this, DisclaimerActivity.class);
|
||||||
|
intent.putExtra("forceShow", true);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.MeasurementDataManager;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
|
||||||
|
import java.util.Timer;
|
||||||
|
import java.util.TimerTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-06-10
|
||||||
|
*/
|
||||||
|
public class EnterHeightActivity extends Activity {
|
||||||
|
private static final String TAG = "EnterHeightActivity";
|
||||||
|
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private Timer mConnTimer;
|
||||||
|
|
||||||
|
private UserMeta mUser;
|
||||||
|
|
||||||
|
private com.shawnlin.numberpicker.NumberPicker mHeightPicker;
|
||||||
|
private ImageView mConnection;
|
||||||
|
private IMeasurementController.MeasurementMode mMode;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_s02a_enter_height);
|
||||||
|
|
||||||
|
mDataManager = MeasurementDataManager.getInstance(getApplicationContext());
|
||||||
|
|
||||||
|
mHeightPicker = (com.shawnlin.numberpicker.NumberPicker) findViewById(R.id.height);
|
||||||
|
mConnection = (ImageView) findViewById(R.id.connection);
|
||||||
|
|
||||||
|
postConnImage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
startTimer();
|
||||||
|
|
||||||
|
mMode = (IMeasurementController.MeasurementMode) getIntent().getSerializableExtra("mode");
|
||||||
|
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
if(mUser == null)
|
||||||
|
throw new IllegalStateException("should pass in UserMeta from EnterAgeActivity");
|
||||||
|
|
||||||
|
if(mUser.height != null) {
|
||||||
|
Log.i(TAG, "got height=" + mUser.height);
|
||||||
|
mHeightPicker.setValue((int) (mUser.height * 100.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int CONN_UPDATE_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
private void startTimer() {
|
||||||
|
if(mConnTimer == null) {
|
||||||
|
mConnTimer = new Timer();
|
||||||
|
mConnTimer.schedule(new TimerTask() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
updateConnectionDisplay();
|
||||||
|
}
|
||||||
|
}, CONN_UPDATE_INTERVAL_MS, CONN_UPDATE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopTimer() {
|
||||||
|
if(mConnTimer != null) {
|
||||||
|
mConnTimer.cancel();
|
||||||
|
mConnTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateConnectionDisplay() {
|
||||||
|
String appId = AppInfo.getInstance(this).getAppMeta().appId;
|
||||||
|
mDataManager.ping(appId, new IMeasurementDataManager.PingResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult() {
|
||||||
|
postConnImage(R.drawable.connection_ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError() {
|
||||||
|
postConnImage(R.drawable.connection_nok);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postConnImage(final int resId) {
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mConnection.setImageResource(resId);
|
||||||
|
mConnection.invalidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(View view) {
|
||||||
|
//Intent intent = new Intent(EnterHeightActivity.this, TimeActivity.class);
|
||||||
|
Intent intent = new Intent(EnterHeightActivity.this, EnterGenderActivity.class);
|
||||||
|
mUser.height = mHeightPicker.getValue() / 1e2;
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
intent.putExtra("mode", mMode);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stats(View view) {
|
||||||
|
Intent intent = new Intent(EnterHeightActivity.this, ResultListActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stetho(View view) {
|
||||||
|
Intent intent = new Intent(EnterHeightActivity.this, PcgActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void help(View view) {
|
||||||
|
Intent intent = new Intent(EnterHeightActivity.this, DisclaimerActivity.class);
|
||||||
|
intent.putExtra("forceShow", true);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/src/main/java/net/heartshield/prevent/ErrorActivity.java
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-06-01
|
||||||
|
*/
|
||||||
|
public class ErrorActivity extends Activity {
|
||||||
|
private UserMeta mUser;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
String message = getIntent().getStringExtra("message");
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
if(message == null)
|
||||||
|
message = "";
|
||||||
|
|
||||||
|
TextView description = (TextView) findViewById(R.id.description);
|
||||||
|
description.setText(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ok(View view) {
|
||||||
|
Intent intent = new Intent(ErrorActivity.this, MeasureActivity.class);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/src/main/java/net/heartshield/prevent/FailActivity.java
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.TextureView;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import net.heartshield.control.Device;
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.control.MeasurementController;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
import net.heartshield.sensors.IntensityDetector;
|
||||||
|
import net.heartshield.sensors.SensorFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* TODO: this is a duplicate of PlaceActivity. Not quite ideal.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-06-01
|
||||||
|
*/
|
||||||
|
public class FailActivity extends Activity {
|
||||||
|
private UserMeta mUser;
|
||||||
|
private IMeasurementController mController;
|
||||||
|
private TextureView mCameraPreview;
|
||||||
|
private FancyButton mStartButton;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_fail);
|
||||||
|
|
||||||
|
mCameraPreview = (TextureView) findViewById(R.id.cameraPreview);
|
||||||
|
mStartButton = (FancyButton) findViewById(R.id.startButton);
|
||||||
|
|
||||||
|
// disable by default until camera signal looks good enough
|
||||||
|
mStartButton.setEnabled(false);
|
||||||
|
|
||||||
|
mController = new MeasurementController(getMainLooper(), new Device(this), new SensorFactory(this), mCameraPreview);
|
||||||
|
mController.setListener(mListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
|
||||||
|
mController.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
try {
|
||||||
|
mController.stop();
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(View view) {
|
||||||
|
Intent intent = new Intent(FailActivity.this, MeasureActivity.class);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IMeasurementController.Listener mListener = new IMeasurementController.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onState(IMeasurementController.State state) {
|
||||||
|
if(state.equals(IMeasurementController.State.SAMPLING)) {
|
||||||
|
// nice
|
||||||
|
mStartButton.setEnabled(true);
|
||||||
|
} else if(state.equals(IMeasurementController.State.WAITING)) {
|
||||||
|
// wait
|
||||||
|
mStartButton.setEnabled(false);
|
||||||
|
} else if(state.equals(IMeasurementController.State.FAILED)) {
|
||||||
|
Toast.makeText(FailActivity.this, "Camera failed", Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrame(double timeSinceStart, double timeSinceLock, double[] rgb, IntensityDetector.ImageSummary summary) {
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
97
app/src/main/java/net/heartshield/prevent/FancyButton.java
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.drawable.ColorDrawable;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.support.v4.content.ContextCompat;
|
||||||
|
import android.support.v7.widget.AppCompatButton;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Button that changes color when pressed.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-01
|
||||||
|
*/
|
||||||
|
public class FancyButton extends AppCompatButton {
|
||||||
|
private int mHighlightColor;
|
||||||
|
private int mRegularColor;
|
||||||
|
private int mDisabledColor;
|
||||||
|
private boolean mEnabled;
|
||||||
|
|
||||||
|
public FancyButton(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
public FancyButton(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init(context, attrs);
|
||||||
|
}
|
||||||
|
public FancyButton(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
init(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
mEnabled = enabled;
|
||||||
|
if(mEnabled)
|
||||||
|
setBackgroundColor(mRegularColor);
|
||||||
|
else
|
||||||
|
setBackgroundColor(mDisabledColor);
|
||||||
|
postInvalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init(Context context, AttributeSet attrs) {
|
||||||
|
mEnabled = true;
|
||||||
|
mHighlightColor = ContextCompat.getColor(context, R.color.square_image_background_highlight);
|
||||||
|
|
||||||
|
int regularColorFallback = ContextCompat.getColor(context, R.color.square_image_background_regular);
|
||||||
|
|
||||||
|
mDisabledColor = ContextCompat.getColor(context, R.color.square_image_background_disabled);
|
||||||
|
|
||||||
|
int color = regularColorFallback;
|
||||||
|
Drawable background = this.getBackground();
|
||||||
|
if (background instanceof ColorDrawable)
|
||||||
|
color = ((ColorDrawable) background).getColor();
|
||||||
|
|
||||||
|
mRegularColor = color;
|
||||||
|
|
||||||
|
|
||||||
|
setOnTouchListener(new OnTouchListener() {
|
||||||
|
@Override
|
||||||
|
public boolean onTouch(View v, MotionEvent event) {
|
||||||
|
switch (event.getAction()) {
|
||||||
|
case MotionEvent.ACTION_DOWN: {
|
||||||
|
AppCompatButton view = (AppCompatButton) v;
|
||||||
|
//view.setBackgroundColor(0x80800000);
|
||||||
|
view.setBackgroundColor(mEnabled ? mHighlightColor : mDisabledColor);
|
||||||
|
|
||||||
|
// ContextCompat.getColor(context, R.color.my_color)
|
||||||
|
//view.getBackground().setColorFilter(0x77800000, PorterDuff.Mode.SRC_ATOP);
|
||||||
|
v.invalidate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
|
||||||
|
// Your action here on button click
|
||||||
|
if(mEnabled)
|
||||||
|
callOnClick(); // either this, or return false (which has other side effects)
|
||||||
|
|
||||||
|
// fall through to clear color filter
|
||||||
|
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_CANCEL: {
|
||||||
|
AppCompatButton view = (AppCompatButton) v;
|
||||||
|
view.setBackgroundColor(mEnabled ? mRegularColor : mDisabledColor);
|
||||||
|
//view.getBackground().clearColorFilter();
|
||||||
|
view.invalidate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
//return false; // make sure onClick() gets fired as well // avoids ACTION_UP for not-listened onClick()???
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
157
app/src/main/java/net/heartshield/prevent/HomeActivity.java
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import net.heartshield.control.ReleaseConfig;
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
import net.heartshield.data.DataManagerFactory;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.MeasurementMeta;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.IVec;
|
||||||
|
import net.heartshield.ui.PermissionHelper;
|
||||||
|
|
||||||
|
import java.text.MessageFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home screen showing the logo and big menu buttons.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-01
|
||||||
|
*/
|
||||||
|
public class HomeActivity extends Activity {
|
||||||
|
private static final String TAG = "HomeActivity";
|
||||||
|
|
||||||
|
private PermissionHelper mPermissions;
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private boolean mFirstOpen = true;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_home);
|
||||||
|
|
||||||
|
// TODO: catch RuntimeError for external storage unavailable, show error dialog explaining the trouble
|
||||||
|
String appId = AppInfo.getInstance(this).getAppMeta().appId;
|
||||||
|
|
||||||
|
TextView ver = (TextView) findViewById(R.id.versionText);
|
||||||
|
MessageFormat fmt = new MessageFormat(getResources().getString(BuildConfig.DEBUG ? R.string.version_debug : R.string.version_release));
|
||||||
|
ver.setText(fmt.format(new Object[]{BuildConfig.VERSION_NAME, BuildConfig.GIT_HASH, appId}));
|
||||||
|
|
||||||
|
mPermissions = new PermissionHelper(this, ReleaseConfig.PERMISSIONS);
|
||||||
|
mPermissions.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void measure(View view) {
|
||||||
|
if(AppInfo.getInstance(this).devSettings.skipDialogs) {
|
||||||
|
// if skipDialogs is set in dev settings, jump straight to measurement, assuming 25 male
|
||||||
|
Intent intent = new Intent(HomeActivity.this, MeasureActivity.class);
|
||||||
|
intent.putExtra("meta", new UserMeta(25, UserMeta.Gender.MALE, 1.96));
|
||||||
|
startActivity(intent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent intent = new Intent(HomeActivity.this, EnterAgeActivity.class);
|
||||||
|
intent.putExtra("mode", IMeasurementController.MeasurementMode.RISK);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stats(View view) {
|
||||||
|
Intent intent = new Intent(HomeActivity.this, ResultListActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stetho(View view) {
|
||||||
|
// Alexis or David only.
|
||||||
|
//if(AppInfo.getInstance(this).getAppMeta().appId.equals("9189008E") || BuildConfig.BUILD_TYPE.equals("debug")) {
|
||||||
|
if(true) {
|
||||||
|
//Intent intent = new Intent(HomeActivity.this, MeasureActivity.class);
|
||||||
|
//Intent intent = new Intent(HomeActivity.this, BreatheActivity.class);
|
||||||
|
|
||||||
|
Intent intent = new Intent(HomeActivity.this, EnterAgeActivity.class);
|
||||||
|
//intent.putExtra("mode", IMeasurementController.MeasurementMode.RISK);
|
||||||
|
//startActivity(intent);
|
||||||
|
|
||||||
|
//intent.putExtra("mode", IMeasurementController.MeasurementMode.VITAL_CHECK);
|
||||||
|
intent.putExtra("mode", IMeasurementController.MeasurementMode.SWEEP_BREATHING);
|
||||||
|
intent.putExtra("meta", new UserMeta(0, null, null));
|
||||||
|
startActivity(intent);
|
||||||
|
} else {
|
||||||
|
// ignore (since PcgActivity returns immediately)
|
||||||
|
Intent intent = new Intent(HomeActivity.this, PcgActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void help(View view) {
|
||||||
|
Intent intent = new Intent(HomeActivity.this, DisclaimerActivity.class);
|
||||||
|
intent.putExtra("forceShow", true);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Double> mLogoClicks = new ArrayList<>();
|
||||||
|
public void logoClick(View view) {
|
||||||
|
// tapping icon for developer options only works in Debug builds
|
||||||
|
if(!BuildConfig.DEBUG)
|
||||||
|
return;
|
||||||
|
|
||||||
|
mLogoClicks.add(System.nanoTime() / 1e9);
|
||||||
|
|
||||||
|
final int NUM_TAPS = 8;
|
||||||
|
final double MAX_DT = 0.3;
|
||||||
|
if(mLogoClicks.size() < NUM_TAPS)
|
||||||
|
return;
|
||||||
|
|
||||||
|
double[] sp = DVec.toArray(mLogoClicks);
|
||||||
|
boolean tapsUnlocked = IVec.all(DVec.lt(DVec.diff(DVec.get(sp, sp.length - NUM_TAPS, sp.length)), DVec.mul(DVec.ones(NUM_TAPS-1), MAX_DT)));
|
||||||
|
if(tapsUnlocked) {
|
||||||
|
Intent intent = new Intent(HomeActivity.this, DevSettingsActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
// for background sync
|
||||||
|
mDataManager = DataManagerFactory.getInstance().getDataManager(this);
|
||||||
|
|
||||||
|
if(mFirstOpen) {
|
||||||
|
// only show sync hint Toasts on first open (otherwise, it is quite annoying every time you return to home screen)
|
||||||
|
mDataManager.setSyncListener(new IMeasurementDataManager.SyncListener() {
|
||||||
|
@Override
|
||||||
|
public void onProgress(int synced, int candidates) {
|
||||||
|
if(synced == 0 && candidates > 0) {
|
||||||
|
Toast.makeText(HomeActivity.this, "Syncing " + candidates + " measurements...", Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFinished(int synced, int candidates) {
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mDataManager.startBackgroundThread();
|
||||||
|
mDataManager.startBackgroundSync();
|
||||||
|
|
||||||
|
mFirstOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
mDataManager.stopBackgroundThread();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/src/main/java/net/heartshield/prevent/InfoActivity.java
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-26
|
||||||
|
*/
|
||||||
|
public class InfoActivity extends Activity {
|
||||||
|
private UserMeta mUser;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_s04_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void next(View view) {
|
||||||
|
Intent intent = new Intent(InfoActivity.this, PlaceActivity.class);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-27
|
||||||
|
*/
|
||||||
|
public class LicenseInfoActivity extends Activity {
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_license_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ok(View view) {
|
||||||
|
Intent intent = new Intent(LicenseInfoActivity.this, DisclaimerActivity.class);
|
||||||
|
intent.putExtra("forceShow", true);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
689
app/src/main/java/net/heartshield/prevent/MeasureActivity.java
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.res.TypedArray;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.os.Vibrator;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.TextureView;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.github.mikephil.charting.charts.LineChart;
|
||||||
|
|
||||||
|
import net.heartshield.control.Device;
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.control.MeasurementController;
|
||||||
|
import net.heartshield.data.AppInfo;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.Measurement;
|
||||||
|
import net.heartshield.data.MeasurementDataManager;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
import net.heartshield.filter.PlotFilter;
|
||||||
|
import net.heartshield.sensors.IntensityDetector;
|
||||||
|
import net.heartshield.sensors.SensorFactory;
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.RunningQuality;
|
||||||
|
import net.heartshield.signal.Signal;
|
||||||
|
import net.heartshield.ui.LineChartWrapper;
|
||||||
|
|
||||||
|
import static net.heartshield.prevent.MeasureActivity.LogMessageType.ERROR;
|
||||||
|
import static net.heartshield.prevent.MeasureActivity.LogMessageType.MEASUREMENT_FAILURE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measurement screen with countdown timer and running wave shape.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-01
|
||||||
|
*/
|
||||||
|
public class MeasureActivity extends Activity {
|
||||||
|
private static final String TAG = "MeasureActivity";
|
||||||
|
|
||||||
|
private UserMeta mUser;
|
||||||
|
private IMeasurementController.MeasurementMode mMode;
|
||||||
|
private IMeasurementController mController;
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private AppInfo mAppInfo;
|
||||||
|
|
||||||
|
private HandlerThread mBackgroundThread;
|
||||||
|
private Handler mBackgroundHandler;
|
||||||
|
|
||||||
|
private LineChartWrapper mChart;
|
||||||
|
PlotFilter mPlotFilter;
|
||||||
|
private static final double FPS = 30.0;
|
||||||
|
|
||||||
|
/** we require a lock (including SQI lock) by this amount of time, and fail the measurement otherwise. this should allow plenty of time to settle all swings and get solid beats detected. */
|
||||||
|
//final double REQUIRE_LOCK_BY_TIME = 30.0;
|
||||||
|
private static final double TOTAL_TIME_RISK = 75.0;
|
||||||
|
public static final double FRONT_TIME_ENDOTHELIAL_FUNCTION = 75.0;
|
||||||
|
private static final double TOTAL_TIME_ENDOTHELIAL_FUNCTION = FRONT_TIME_ENDOTHELIAL_FUNCTION + 300.0 + 75.0;
|
||||||
|
public static final double TOTAL_TIME_VITAL_CHECK = 10.0;
|
||||||
|
public static final double FRONT_TIME_VITAL_CHECK = 5.0; // until when it is OK to restart measurement on large swings (e.g. when placing the phone)
|
||||||
|
double mTotalTime;
|
||||||
|
//final double TOTAL_TIME = 15.0;
|
||||||
|
final boolean TEST_MODE = false; // if true, do not save Measurement on server
|
||||||
|
private TextView mCountdownText;
|
||||||
|
private TextView mHeartRateText;
|
||||||
|
private ImageView mProgressCircle;
|
||||||
|
private ImageView mSignalIndicator;
|
||||||
|
private TextureView mCameraPreview = null;
|
||||||
|
private TypedArray mProgSequence;
|
||||||
|
private TypedArray mTxSequence;
|
||||||
|
|
||||||
|
private int mNumNoisy = 0;
|
||||||
|
private static final int MAX_NUM_NOISY = 15; // + give a few for Signal.beatdetect() errors.
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_measure);
|
||||||
|
|
||||||
|
//mCameraPreview = (TextureView) findViewById(R.id.cameraPreview);
|
||||||
|
mCountdownText = (TextView) findViewById(R.id.countdown);
|
||||||
|
mHeartRateText = (TextView) findViewById(R.id.heartRate);
|
||||||
|
mProgressCircle = (ImageView) findViewById(R.id.progressCircle);
|
||||||
|
mSignalIndicator = (ImageView) findViewById(R.id.signalIndicator);
|
||||||
|
mProgSequence = getResources().obtainTypedArray(R.array.progress_circle_sequence);
|
||||||
|
mTxSequence = getResources().obtainTypedArray(R.array.signal_transmit_sequence);
|
||||||
|
|
||||||
|
mHeartRateText.setText("- ");
|
||||||
|
mSignalIndicator.setImageResource(0);
|
||||||
|
mProgressCircle.invalidate();
|
||||||
|
|
||||||
|
mController = new MeasurementController(getMainLooper(), new Device(this), new SensorFactory(this), mCameraPreview);
|
||||||
|
mController.setListener(mListener);
|
||||||
|
|
||||||
|
// server does not save then. maybe we do want to save... maybe create another test flag, for not saving?
|
||||||
|
//if(AppInfo.getInstance(this).devSettings.skipDialogs)
|
||||||
|
// mController.setUnderTest(true);
|
||||||
|
|
||||||
|
mChart = new LineChartWrapper((LineChart) findViewById(R.id.chart));
|
||||||
|
final int PLOT_SAMPLES = (int) (FPS * 3.0); // see LineChartWrapper.WINDOW_SIZE_SECS
|
||||||
|
|
||||||
|
// TODO(david): PlotFilter should be more robust, not just TOTAL_TIME
|
||||||
|
mPlotFilter = new PlotFilter(FPS, (int) ((mTotalTime + 10.0) * FPS + 1), PLOT_SAMPLES);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* when integrating RunningQuality into MeasurementController, keep in mind the necessity
|
||||||
|
* of a separate background worker thread.
|
||||||
|
*/
|
||||||
|
mQuality = new RunningQuality(FPS);
|
||||||
|
mQuality.setListener(new RunningQuality.BeatListener() {
|
||||||
|
private int mBadBeatStartIndex = 0;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLocked(int startIdx) {
|
||||||
|
Log.i(TAG, "onLocked() at startIdx=" + startIdx);
|
||||||
|
mNumNoisy = 0;
|
||||||
|
mBadBeatStartIndex = 0;
|
||||||
|
|
||||||
|
double qualityLockTime = mQuality.getTimeAt(startIdx);
|
||||||
|
mController.setQualityLockTime(qualityLockTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBeat(int startIdx, boolean goodBeat, double posCorrCoeff) {
|
||||||
|
//double time = mQuality.getTimeAt(startIdx);
|
||||||
|
Log.i(TAG, "onBeat() SQI: posCorrCoeff=" + posCorrCoeff);
|
||||||
|
if(!goodBeat) {
|
||||||
|
mNumNoisy++;
|
||||||
|
|
||||||
|
double t = mQuality.getTimeAt(startIdx);
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT");
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT");
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT <<<<<<<< noisy beat #" + mNumNoisy);
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT");
|
||||||
|
Log.i(TAG, "onBeat() NOISY BEAT");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// ignored for doctors
|
||||||
|
if(mNumNoisy > MAX_NUM_NOISY) {
|
||||||
|
try {
|
||||||
|
mController.stop();
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
startErrorActivity(getString(R.string.frames_dropped));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMeasurementFailure(mController.getMeasurement());
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mDataManager = MeasurementDataManager.getInstance(getApplicationContext());
|
||||||
|
|
||||||
|
mAppInfo = AppInfo.getInstance(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SignalAnimator extends Thread {
|
||||||
|
private boolean running = true;
|
||||||
|
private int i;
|
||||||
|
|
||||||
|
SignalAnimator() {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized private boolean isRunning() { return running; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
i = 0;
|
||||||
|
|
||||||
|
while(isRunning()) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(250);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
//mSignalIndicator.setImageResource();
|
||||||
|
|
||||||
|
final int ii = i;
|
||||||
|
|
||||||
|
if(++i >= mTxSequence.length())
|
||||||
|
i = 0;
|
||||||
|
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mSignalIndicator.setImageResource(mTxSequence.getResourceId(ii, -1));
|
||||||
|
mSignalIndicator.invalidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void cancel() {
|
||||||
|
running = false;
|
||||||
|
interrupt();
|
||||||
|
try {
|
||||||
|
join();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SignalAnimator mAnimator;
|
||||||
|
|
||||||
|
private void startAnimation() {
|
||||||
|
if(mAnimator != null)
|
||||||
|
return; // already animating
|
||||||
|
mAnimator = new SignalAnimator();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopAnimation() {
|
||||||
|
if(mAnimator != null) {
|
||||||
|
mAnimator.cancel();
|
||||||
|
mAnimator = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
mMode = (IMeasurementController.MeasurementMode) getIntent().getSerializableExtra("mode");
|
||||||
|
Log.i(TAG, "got age=" + mUser.age + " gender=" + mUser.gender + " mode=" + mMode);
|
||||||
|
|
||||||
|
if(mMode == null) {
|
||||||
|
// to do: e.g. 'Repeat measurement' button in ResultActivity
|
||||||
|
Log.w(TAG, "should migrate all calling Activities to include mode");
|
||||||
|
mMode = IMeasurementController.MeasurementMode.RISK;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(mMode) {
|
||||||
|
default: // fall through
|
||||||
|
case RISK:
|
||||||
|
mTotalTime = TOTAL_TIME_RISK;
|
||||||
|
break;
|
||||||
|
case ENDOTHELIAL_FUNCTION:
|
||||||
|
mTotalTime = TOTAL_TIME_ENDOTHELIAL_FUNCTION;
|
||||||
|
break;
|
||||||
|
case VITAL_CHECK:
|
||||||
|
mTotalTime = TOTAL_TIME_VITAL_CHECK;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
mController.setMode(mMode);
|
||||||
|
|
||||||
|
mController.setUnderTest(mDataManager.isUnderTest());
|
||||||
|
|
||||||
|
mPlotFilter.clear();
|
||||||
|
mQuality.clear();
|
||||||
|
mController.start();
|
||||||
|
|
||||||
|
if(mDataManager.getNumFailedMeasurements() > 0)
|
||||||
|
mQuality.setBeatCorrThr2(RunningQuality.BEAT_CORR_THR_2_LOWER);
|
||||||
|
else
|
||||||
|
mQuality.setBeatCorrThr2(RunningQuality.BEAT_CORR_THR_2);
|
||||||
|
|
||||||
|
mCountdownText.setText(Integer.toString((int) mTotalTime));
|
||||||
|
mProgressCircle.setImageResource(mProgSequence.getResourceId(0, -1));
|
||||||
|
mProgressCircle.invalidate();
|
||||||
|
|
||||||
|
mSignalIndicator.setImageResource(0);
|
||||||
|
mProgressCircle.invalidate();
|
||||||
|
|
||||||
|
startBackgroundThread();
|
||||||
|
mDataManager.startBackgroundThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onMeasurementFinished(Measurement measurement) {
|
||||||
|
updateMeasurement(measurement);
|
||||||
|
|
||||||
|
startAnimation();
|
||||||
|
|
||||||
|
// if dummy microphone, pretend we're already synced
|
||||||
|
if(measurement.series.getAudio().length == 0)
|
||||||
|
measurement.series.audioSynced = true;
|
||||||
|
|
||||||
|
mDataManager.create(measurement, new IMeasurementDataManager.MeasurementResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(Measurement measurement) {
|
||||||
|
//assert(measurement.result != null);
|
||||||
|
|
||||||
|
// successful result from server
|
||||||
|
Intent intent = new Intent(MeasureActivity.this, ResultActivity.class);
|
||||||
|
intent.putExtra("measurementId", measurement.getId());
|
||||||
|
startActivity(intent);
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
onCreateError(measurement, code, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onCreateError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
// some error saving the stuff
|
||||||
|
Toast.makeText(MeasureActivity.this, "Failed to get result: " + code + " " + message, Toast.LENGTH_LONG).show();
|
||||||
|
Log.e(TAG, "Failed to get result: " + code + " " + message);
|
||||||
|
stopAnimation();
|
||||||
|
|
||||||
|
int failureReason;
|
||||||
|
|
||||||
|
// note: UNCLASSIFIED is a regular, protocol-level response that is not an error.
|
||||||
|
// hence it is not handled here, but displayed in ResultActivity in a special way
|
||||||
|
switch(code) {
|
||||||
|
case CONNECTION_ERROR:
|
||||||
|
failureReason = R.drawable.signal_none;
|
||||||
|
break;
|
||||||
|
case HTTP_ERROR:
|
||||||
|
case LOCAL_ERROR:
|
||||||
|
case UNKNOWN_ERROR:
|
||||||
|
case PROTOCOL_ERROR:
|
||||||
|
failureReason = R.drawable.signal_fail;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
failureReason = R.drawable.signal_fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
mSignalIndicator.setImageResource(failureReason);
|
||||||
|
mSignalIndicator.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogMessageType {
|
||||||
|
MEASUREMENT_FAILURE,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onMeasurementFailure(Measurement measurement) {
|
||||||
|
|
||||||
|
updateMeasurement(measurement);
|
||||||
|
|
||||||
|
int numFailedBefore = mDataManager.getNumFailedMeasurements();
|
||||||
|
mDataManager.logMessage(measurement.appId, MEASUREMENT_FAILURE.toString(), "measurement failure. numFailedBefore=" + numFailedBefore);
|
||||||
|
|
||||||
|
mDataManager.addFailedMeasurement();
|
||||||
|
|
||||||
|
startAnimation();
|
||||||
|
|
||||||
|
mDataManager.logFailure(measurement, new IMeasurementDataManager.MeasurementResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(Measurement measurement) {
|
||||||
|
// show result from server (UNCLASSIFIED)
|
||||||
|
Intent intent = new Intent(MeasureActivity.this, ResultActivity.class);
|
||||||
|
intent.putExtra("measurementId", measurement.getId());
|
||||||
|
startActivity(intent);
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
onCreateError(measurement, code, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateMeasurement(Measurement measurement) {
|
||||||
|
measurement.user = mUser;
|
||||||
|
measurement.app = mAppInfo.getAppMeta();
|
||||||
|
|
||||||
|
measurement.meta.mode = mMode;
|
||||||
|
measurement.meta.test = TEST_MODE;
|
||||||
|
|
||||||
|
// if dummy microphone, pretend we're already synced
|
||||||
|
if(measurement.series.getAudio().length == 0)
|
||||||
|
measurement.series.audioSynced = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
|
||||||
|
try {
|
||||||
|
mController.stop();
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
// ignored, since result is discarded here anyways
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAnimation();
|
||||||
|
stopBackgroundThread();
|
||||||
|
|
||||||
|
mDataManager.stopBackgroundThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two layers of signal quality checks:
|
||||||
|
*
|
||||||
|
* 1. finger presence: (redness, uniformity, limit brightness-swings)
|
||||||
|
* * restarts if you take off your finger
|
||||||
|
* vibrates on restarts
|
||||||
|
* * 30s timer limit if never locked for at least 10s
|
||||||
|
* TODO: BUG: if never locked, timeSinceLock keeps increasing.
|
||||||
|
*
|
||||||
|
* 2. pulse beat quality: (beat detection and cross-correlation of beats)
|
||||||
|
* * locks if 2+ similar beats are found
|
||||||
|
* * only shows BPM if the quality is locked
|
||||||
|
*/
|
||||||
|
|
||||||
|
private RunningQuality mQuality;
|
||||||
|
|
||||||
|
/** filter and plot the red channel of the camera */
|
||||||
|
private IMeasurementController.Listener mListener = new IMeasurementController.Listener() {
|
||||||
|
private int mI;
|
||||||
|
private IMeasurementController.State mPrevState = IMeasurementController.State.STOPPED;
|
||||||
|
private double mTimeSinceLock = 0.0;
|
||||||
|
private boolean mEverLocked = false;
|
||||||
|
|
||||||
|
private void clear() {
|
||||||
|
mI = 0;
|
||||||
|
mPrevSigTs = 0.0;
|
||||||
|
mTimeSinceLock = 0.0;
|
||||||
|
efBuzzed1 = false;
|
||||||
|
efBuzzed2 = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onState(IMeasurementController.State newState) {
|
||||||
|
if(newState == IMeasurementController.State.STOPPED || newState == IMeasurementController.State.FAILED)
|
||||||
|
clear();
|
||||||
|
|
||||||
|
if(newState == IMeasurementController.State.FAILED) {
|
||||||
|
if(mController.getFailureReason().equals(IMeasurementController.FailureReason.CAMERA_FRAMES_DROPPED)) {
|
||||||
|
startErrorActivity(getString(R.string.frames_dropped));
|
||||||
|
} else {
|
||||||
|
startErrorActivity("Error: Measurement failed: " + mController.getFailureReason().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(newState == IMeasurementController.State.SAMPLING) {
|
||||||
|
if(!mEverLocked) {
|
||||||
|
// short vibrate at first lock
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(200);
|
||||||
|
}
|
||||||
|
mEverLocked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mPrevState == IMeasurementController.State.SAMPLING && newState == IMeasurementController.State.WAITING) {
|
||||||
|
// avoid message when user puts their finger on (bouncing camera lock)
|
||||||
|
if(mTimeSinceLock > 5.0) {
|
||||||
|
Toast.makeText(MeasureActivity.this, "Measurement restarted", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
mQuality.clear();
|
||||||
|
// note: both SQI and swing-level restarts go through here
|
||||||
|
|
||||||
|
// vibrate on restart
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
mPrevState = newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double mPrevSigTs = 0.0;
|
||||||
|
|
||||||
|
/** beatdetect() for SQI and BPM will run on the signal starting from (cameraLockTime + SQI_SETTLING_TIME) */
|
||||||
|
private static final double SQI_SETTLING_TIME = 3.0;
|
||||||
|
|
||||||
|
private boolean efBuzzed1 = false;
|
||||||
|
private boolean efBuzzed2 = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrame(final double timeSinceStart, final double timeSinceLock, double[] rgb, IntensityDetector.ImageSummary summary) {
|
||||||
|
final int RED_CHANNEL = 0;
|
||||||
|
final double entry = rgb[RED_CHANNEL];
|
||||||
|
|
||||||
|
Log.i(TAG, "ON_FRAME " + timeSinceStart + " " + timeSinceLock + " " + rgb[0] + " " + rgb[1] + " " + rgb[2]);
|
||||||
|
|
||||||
|
mTimeSinceLock = timeSinceLock;
|
||||||
|
|
||||||
|
if(++mI % 30 == 0) {
|
||||||
|
mCountdownText.setText(Integer.toString(((int) (mTotalTime - timeSinceLock))));
|
||||||
|
|
||||||
|
int progSeqId = (int) (mProgSequence.length() * timeSinceLock / mTotalTime);
|
||||||
|
progSeqId = Math.min(progSeqId, mProgSequence.length()-1);
|
||||||
|
mProgressCircle.setImageResource(mProgSequence.getResourceId(progSeqId, -1));
|
||||||
|
mProgressCircle.invalidate();
|
||||||
|
|
||||||
|
// when pressing Back, it happens that mBackgroundHandler is null here - onPause() called before running this callback Runnable
|
||||||
|
// we could check if Activity is paused before dispatching in MeasurementController, but then we'd need to hand the Activity to it.
|
||||||
|
if(mBackgroundHandler != null) {
|
||||||
|
|
||||||
|
if(mQuality.isRunning()) {
|
||||||
|
mBackgroundHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// takes some time, at longer signals (since it does full beat detection)
|
||||||
|
// run it in the background to avoid UI lag of the graph plotting
|
||||||
|
updateHeartRate(mController.getLockTime() + SQI_SETTLING_TIME);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// running signal quality
|
||||||
|
//
|
||||||
|
|
||||||
|
if(mPrevState == IMeasurementController.State.SAMPLING && timeSinceLock > SQI_SETTLING_TIME) {
|
||||||
|
mBackgroundHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// use signal since lock, otherwise the amplitude in Signal.beatdetect() takes too long to settle
|
||||||
|
if(mPrevSigTs == 0.0)
|
||||||
|
mPrevSigTs = mController.getLockTime() + SQI_SETTLING_TIME;
|
||||||
|
|
||||||
|
// must use detrended data, since the DC would mean the correlations will look too good to be true.
|
||||||
|
PlotFilter.Series ser = getSeriesSinceLock(mPrevSigTs);
|
||||||
|
|
||||||
|
if(ser.t.length > 0) {
|
||||||
|
mQuality.append(ser.t[0], ser.x);
|
||||||
|
Log.i(TAG, "mQuality.append() of pts.length=" + ser.x.length);
|
||||||
|
|
||||||
|
mPrevSigTs = ser.t[ser.t.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: if the camera was never locked (unlikely), `timeSinceLock` reports same as `timeSinceStart`
|
||||||
|
|
||||||
|
//boolean lockOverdue = timeSinceLock > REQUIRE_LOCK_BY_TIME; // camera lock <--- long time ---> ?
|
||||||
|
|
||||||
|
boolean lockOverdue = false; // disabled for doctors - always finish the measurement.
|
||||||
|
|
||||||
|
if(lockOverdue && !mQuality.isRunning()) {
|
||||||
|
try {
|
||||||
|
mController.stop();
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
startErrorActivity(getString(R.string.frames_dropped));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMeasurementFailure(mController.getMeasurement());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bad: since more than 30 secs, there was no lock of over 10 sec duration?
|
||||||
|
boolean finished = timeSinceLock > mTotalTime;
|
||||||
|
boolean notLockedTrouble = (timeSinceStart > 30.0 && timeSinceLock < 10.0); // && (mMode.equals(IMeasurementController.MeasurementMode.RISK) || (mMode.equals(IMeasurementController.MeasurementMode.ENDOTHELIAL_FUNCTION) && timeSinceStart < TOTAL_TIME_RISK))
|
||||||
|
|
||||||
|
if(mMode.equals(IMeasurementController.MeasurementMode.ENDOTHELIAL_FUNCTION) && timeSinceLock > TOTAL_TIME_RISK && !efBuzzed1) {
|
||||||
|
// buzz 1: inflate cuff (provoke arm ischemia)
|
||||||
|
Toast.makeText(MeasureActivity.this, "Inflate cuff now", Toast.LENGTH_LONG).show();
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(200);
|
||||||
|
efBuzzed1 = true;
|
||||||
|
} else if(mMode.equals(IMeasurementController.MeasurementMode.ENDOTHELIAL_FUNCTION) && timeSinceLock > (TOTAL_TIME_ENDOTHELIAL_FUNCTION - TOTAL_TIME_RISK) && !efBuzzed2) {
|
||||||
|
// buzz 2: deflate cuff (remove arm ischemia)
|
||||||
|
Toast.makeText(MeasureActivity.this, "Deflate cuff now", Toast.LENGTH_LONG).show();
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(200);
|
||||||
|
efBuzzed2 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(finished || notLockedTrouble) {
|
||||||
|
// ensure we only call stop() once
|
||||||
|
if(mController.getState() == IMeasurementController.State.STOPPED)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.i(TAG, "since more than 30 secs, there was no lock of over 10 sec duration. timeSinceLock=" + timeSinceLock + " timeSinceStart=" + timeSinceStart);
|
||||||
|
mController.stop();
|
||||||
|
|
||||||
|
if(mQuality.isRunning() && mNumNoisy <= MAX_NUM_NOISY) {
|
||||||
|
// what if never locked properly? wrong API endpoint?
|
||||||
|
onMeasurementFinished(mController.getMeasurement());
|
||||||
|
} else {
|
||||||
|
// long vibrate on fail
|
||||||
|
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
v.vibrate(new long[]{400, 400, 400, 400}, -1);
|
||||||
|
|
||||||
|
// codepath used for doctors
|
||||||
|
onMeasurementFailure(mController.getMeasurement());
|
||||||
|
}
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
startErrorActivity(getString(R.string.frames_dropped));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Runnable runnable = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mPlotFilter.addUneven(timeSinceStart, entry);
|
||||||
|
PlotFilter.Series slice = mPlotFilter.getPlotSeries();
|
||||||
|
mChart.replaceData(slice.t, slice.x);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runOnUiThread(runnable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private void startOtherActivity(Class<?> activityClass) {
|
||||||
|
Intent intent = new Intent(MeasureActivity.this, activityClass);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
startActivity(intent);
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startErrorActivity(String message) {
|
||||||
|
mDataManager.logMessage(mAppInfo.getAppMeta().appId, ERROR.toString(), message);
|
||||||
|
|
||||||
|
Intent intent = new Intent(MeasureActivity.this, ErrorActivity.class);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
intent.putExtra("message", message);
|
||||||
|
startActivity(intent);
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlotFilter.Series getSeriesSinceLock(double lockTime) {
|
||||||
|
int len = mPlotFilter.size();
|
||||||
|
PlotFilter.Series series = mPlotFilter.getRecentSeries(len);
|
||||||
|
|
||||||
|
int istart = 0;
|
||||||
|
for(; istart < series.t.length; istart++)
|
||||||
|
if(series.t[istart] > lockTime)
|
||||||
|
break;
|
||||||
|
|
||||||
|
double[] x = DVec.get(series.x, istart, series.x.length);
|
||||||
|
double[] t = DVec.get(series.t, istart, series.t.length);
|
||||||
|
return new PlotFilter.Series(t, x);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateHeartRate(double sinceTime) {
|
||||||
|
int len = mPlotFilter.size();
|
||||||
|
|
||||||
|
// ignore the initial, large swing
|
||||||
|
PlotFilter.Series ser = getSeriesSinceLock(sinceTime);
|
||||||
|
double[] pts = ser.x;
|
||||||
|
|
||||||
|
// we should make sure that detrended amplitude swing is much smaller (< e.g. 16, see ipynb exploration) in recent stuff
|
||||||
|
|
||||||
|
Log.i(TAG, "updateHeartRate() len=" + len + " pointsSinceLock.length=" + pts.length);
|
||||||
|
|
||||||
|
final Double bpm = Signal.calculateBpm(FPS, pts);
|
||||||
|
|
||||||
|
if(pts.length >= 60) {
|
||||||
|
double[] smoothd = Signal.running_mean(Signal.running_mean(pts, 6), 7);
|
||||||
|
double[] slice = DVec.get(smoothd, smoothd.length - 30, smoothd.length);
|
||||||
|
Log.i(TAG, "swing amplitude: " + (DVec.max(slice) - DVec.min(slice)));
|
||||||
|
}
|
||||||
|
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if(bpm != null)
|
||||||
|
mHeartRateText.setText(Integer.toString(bpm.intValue()));
|
||||||
|
else
|
||||||
|
mHeartRateText.setText("- ");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startBackgroundThread() {
|
||||||
|
mBackgroundThread = new HandlerThread("MeasureBackground");
|
||||||
|
mBackgroundThread.start();
|
||||||
|
mBackgroundHandler = new Handler(mBackgroundThread.getLooper());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopBackgroundThread() {
|
||||||
|
if(mBackgroundThread != null) {
|
||||||
|
mBackgroundThread.quitSafely();
|
||||||
|
try {
|
||||||
|
mBackgroundThread.join();
|
||||||
|
mBackgroundThread = null;
|
||||||
|
mBackgroundHandler = null;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
52
app/src/main/java/net/heartshield/prevent/PcgActivity.java
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.media.AudioFormat;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.AudioTrack;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import net.heartshield.data.FileUtil;
|
||||||
|
import net.heartshield.filter.FirFilter;
|
||||||
|
import net.heartshield.filter.IRFilterBlock;
|
||||||
|
import net.heartshield.filter.PcgBeatDetector;
|
||||||
|
import net.heartshield.sensors.AudioSensorBase;
|
||||||
|
import net.heartshield.sensors.IAudioSensorBase;
|
||||||
|
import net.heartshield.signal.AudioDecoder;
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.SVec;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-16
|
||||||
|
*/
|
||||||
|
public class PcgActivity extends Activity {
|
||||||
|
private static final String TAG = "PcgActivity";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_pcg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
// bounce back
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/src/main/java/net/heartshield/prevent/PlaceActivity.java
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.TextureView;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import net.heartshield.control.Device;
|
||||||
|
import net.heartshield.control.IMeasurementController;
|
||||||
|
import net.heartshield.control.MeasurementController;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
import net.heartshield.sensors.IntensityDetector;
|
||||||
|
import net.heartshield.sensors.SensorFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* TODO: FailActivity is a duplicate of PlaceActivity. Not quite ideal.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-26
|
||||||
|
*/
|
||||||
|
public class PlaceActivity extends Activity {
|
||||||
|
private UserMeta mUser;
|
||||||
|
private IMeasurementController mController;
|
||||||
|
private TextureView mCameraPreview;
|
||||||
|
private FancyButton mStartButton;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_s05_place);
|
||||||
|
|
||||||
|
mCameraPreview = (TextureView) findViewById(R.id.cameraPreview);
|
||||||
|
mStartButton = (FancyButton) findViewById(R.id.startButton);
|
||||||
|
|
||||||
|
// disable by default until camera signal looks good enough
|
||||||
|
mStartButton.setEnabled(false);
|
||||||
|
|
||||||
|
mController = new MeasurementController(getMainLooper(), new Device(this), new SensorFactory(this), mCameraPreview);
|
||||||
|
mController.setListener(mListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
|
||||||
|
mController.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
try {
|
||||||
|
mController.stop();
|
||||||
|
} catch (IMeasurementController.FramesDroppedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void next(View view) {
|
||||||
|
Intent intent = new Intent(PlaceActivity.this, RelaxActivity.class);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IMeasurementController.Listener mListener = new IMeasurementController.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onState(IMeasurementController.State state) {
|
||||||
|
if(state.equals(IMeasurementController.State.SAMPLING)) {
|
||||||
|
// nice
|
||||||
|
mStartButton.setEnabled(true);
|
||||||
|
} else if(state.equals(IMeasurementController.State.WAITING)) {
|
||||||
|
// wait
|
||||||
|
mStartButton.setEnabled(false);
|
||||||
|
} else if(state.equals(IMeasurementController.State.FAILED)) {
|
||||||
|
Toast.makeText(PlaceActivity.this, "Camera failed", Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrame(double timeSinceStart, double timeSinceLock, double[] rgb, IntensityDetector.ImageSummary summary) {
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
34
app/src/main/java/net/heartshield/prevent/RelaxActivity.java
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-06-02
|
||||||
|
*/
|
||||||
|
public class RelaxActivity extends Activity {
|
||||||
|
private UserMeta mUser;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_s06_relax);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(View view) {
|
||||||
|
Intent intent = new Intent(RelaxActivity.this, MeasureActivity.class);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
345
app/src/main/java/net/heartshield/prevent/ResultActivity.java
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.format.DateFormat;
|
||||||
|
import android.text.method.LinkMovementMethod;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.CheckBox;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.github.mikephil.charting.charts.LineChart;
|
||||||
|
|
||||||
|
import net.heartshield.data.DataManagerFactory;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.Measurement;
|
||||||
|
import net.heartshield.data.MeasurementId;
|
||||||
|
import net.heartshield.data.MeasurementResult;
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.ui.LineChartWrapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result screen showing risk score and various parameters: HRV metrics, pulse shape...
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-10
|
||||||
|
*/
|
||||||
|
public class ResultActivity extends Activity {
|
||||||
|
private static final String TAG = "ResultActivity";
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private Measurement mMeasurement;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_result);
|
||||||
|
|
||||||
|
mDateFormat = DateFormat.getDateFormat(this);
|
||||||
|
mTimeFormat = DateFormat.getTimeFormat(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendMail(View view) {
|
||||||
|
EditText emailText = (EditText) findViewById(R.id.email);
|
||||||
|
CheckBox notifyBetaCheckBox = (CheckBox) findViewById(R.id.notifyBeta);
|
||||||
|
final String email = emailText.getText().toString();
|
||||||
|
final boolean notify = notifyBetaCheckBox.isChecked();
|
||||||
|
if(!email.equals("")) {
|
||||||
|
Toast.makeText(ResultActivity.this, "Sending mail to: " + email, Toast.LENGTH_LONG).show();
|
||||||
|
mMeasurement.user.email = email;
|
||||||
|
mMeasurement.user.notifyBeta = notify;
|
||||||
|
|
||||||
|
FancyButton sendButton = (FancyButton) findViewById(R.id.sendButton);
|
||||||
|
sendButton.setEnabled(false);
|
||||||
|
|
||||||
|
mDataManager.email(mMeasurement, new IMeasurementDataManager.MeasurementResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(Measurement measurement) {
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Toast.makeText(ResultActivity.this, "Result sent to: " + email, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Measurement measurement, final IMeasurementDataManager.ErrorCode code, final String message) {
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Toast.makeText(ResultActivity.this, "Failed to mail result: " + code + " " + message, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void measureAgain(View view) {
|
||||||
|
Class<?> target;
|
||||||
|
|
||||||
|
if((mMeasurement.hasResult() && mMeasurement.result.status == MeasurementResult.Status.UNCLASSIFIED) || mMeasurement.result.status == MeasurementResult.Status.RECORDED) {
|
||||||
|
// repeat measurement, from UNCLASSIFIED result, skip entry of age and gender
|
||||||
|
target = MeasureActivity.class;
|
||||||
|
} else {
|
||||||
|
// new measurement
|
||||||
|
target = EnterAgeActivity.class;
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent intent = new Intent(ResultActivity.this, target);
|
||||||
|
UserMeta meta = mMeasurement.user;
|
||||||
|
intent.putExtra("meta", meta);
|
||||||
|
intent.putExtra("mode", mMeasurement.meta.mode);
|
||||||
|
startActivity(intent);
|
||||||
|
mMeasurement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void edit(View view) {
|
||||||
|
Intent intent = new Intent(ResultActivity.this, EditActivity.class);
|
||||||
|
intent.putExtra("measurementId", mMeasurement.getId());
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
mDataManager = DataManagerFactory.getInstance().getDataManager(getApplicationContext());
|
||||||
|
|
||||||
|
EditText emailText = (EditText) findViewById(R.id.email);
|
||||||
|
CheckBox notifyBetaCheckBox = (CheckBox) findViewById(R.id.notifyBeta);
|
||||||
|
FancyButton sendButton = (FancyButton) findViewById(R.id.sendButton);
|
||||||
|
|
||||||
|
emailText.setText("");
|
||||||
|
notifyBetaCheckBox.setChecked(false);
|
||||||
|
sendButton.setEnabled(true);
|
||||||
|
|
||||||
|
MeasurementId id = (MeasurementId) getIntent().getSerializableExtra("measurementId");
|
||||||
|
Measurement measurement;
|
||||||
|
try {
|
||||||
|
measurement = mDataManager.retrieve(id);
|
||||||
|
mMeasurement = measurement;
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
Toast.makeText(ResultActivity.this, "Local filesystem failure: " + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
|
||||||
|
// go back to Home screen
|
||||||
|
Intent intent = new Intent(ResultActivity.this, HomeActivity.class);
|
||||||
|
startActivity(intent);
|
||||||
|
mMeasurement = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMeasurement(measurement);
|
||||||
|
|
||||||
|
FancyButton measureAgainButton = (FancyButton) findViewById(R.id.measureAgain);
|
||||||
|
if(mMeasurement.hasResult() && mMeasurement.result.status == MeasurementResult.Status.UNCLASSIFIED) {
|
||||||
|
// measure again with UNCLASSIFIED result? skip entry of age and gender
|
||||||
|
measureAgainButton.setText(getString(R.string.measure_repeat));
|
||||||
|
} else {
|
||||||
|
measureAgainButton.setText(getString(R.string.measure_new));
|
||||||
|
}
|
||||||
|
|
||||||
|
mDataManager.startBackgroundThread();
|
||||||
|
mDataManager.startBackgroundSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
mDataManager.stopBackgroundThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final float LINE_WIDTH = 1.0f;
|
||||||
|
private static final int LINE_COLOR = Color.BLACK;
|
||||||
|
|
||||||
|
private java.text.DateFormat mDateFormat;
|
||||||
|
private java.text.DateFormat mTimeFormat;
|
||||||
|
|
||||||
|
private void showMeasurement(Measurement measurement) {
|
||||||
|
View scoreBox = (View) findViewById(R.id.heartScoreBox);
|
||||||
|
View details = (View) findViewById(R.id.details);
|
||||||
|
ImageView resultImage = (ImageView) findViewById(R.id.heartScoreImage);
|
||||||
|
ImageView riskReportImage = (ImageView) findViewById(R.id.riskReportImage);
|
||||||
|
TextView measurementIdText = (TextView) findViewById(R.id.measurementId);
|
||||||
|
TextView disclaimer = (TextView) findViewById(R.id.disclaimer);
|
||||||
|
TextView heartScore = (TextView) findViewById(R.id.heartScore);
|
||||||
|
TextView heartScoreTitle = (TextView) findViewById(R.id.heartScoreTitle);
|
||||||
|
TextView heartScoreDesc = (TextView) findViewById(R.id.heartScoreDesc);
|
||||||
|
TextView heartRate = (TextView) findViewById(R.id.heartRate);
|
||||||
|
TextView date = (TextView) findViewById(R.id.date);
|
||||||
|
TextView date2 = (TextView) findViewById(R.id.date2);
|
||||||
|
TextView whatNow = (TextView) findViewById(R.id.whatNow);
|
||||||
|
TextView heartRateVariability = (TextView) findViewById(R.id.heartRateVariability);
|
||||||
|
//LineChartWrapper chart = new LineChartWrapper((LineChart) findViewById(R.id.chart), LINE_WIDTH, LINE_COLOR);
|
||||||
|
FancyButton editButton = (FancyButton) findViewById(R.id.editButton);
|
||||||
|
|
||||||
|
MeasurementResult result = measurement.result;
|
||||||
|
|
||||||
|
int level = 0; // 0-3, 0 is unknown
|
||||||
|
int[] backgroundRes = {
|
||||||
|
R.color.background_risk_unknown,
|
||||||
|
R.color.background_risk_low,
|
||||||
|
R.color.background_risk_medium,
|
||||||
|
R.color.background_risk_high
|
||||||
|
};
|
||||||
|
int[] descRes = {
|
||||||
|
R.string.desc_risk_unknown,
|
||||||
|
R.string.desc_risk_low,
|
||||||
|
R.string.desc_risk_medium,
|
||||||
|
R.string.desc_risk_high
|
||||||
|
};
|
||||||
|
int[] titleRes = {
|
||||||
|
R.string.title_risk_unknown,
|
||||||
|
R.string.title_risk_low,
|
||||||
|
R.string.title_risk_medium,
|
||||||
|
R.string.title_risk_high
|
||||||
|
};
|
||||||
|
int[] whatRes = {
|
||||||
|
R.string.whatnow_risk_unknown,
|
||||||
|
R.string.whatnow_risk_low,
|
||||||
|
R.string.whatnow_risk_medium,
|
||||||
|
R.string.whatnow_risk_high
|
||||||
|
};
|
||||||
|
// heartIconRes should be consistent with ResultLineAdapter
|
||||||
|
int[] heartIconRes = {
|
||||||
|
R.drawable.help_small, // should be consistent with ResultListActivity -> ResultLineAdapter
|
||||||
|
R.drawable.heart_ok,
|
||||||
|
R.drawable.heart_only,
|
||||||
|
R.drawable.heart_nok
|
||||||
|
};
|
||||||
|
int[] disclaimerRes = {
|
||||||
|
R.string.disclaimer2_low, // unused
|
||||||
|
R.string.disclaimer2_low,
|
||||||
|
R.string.disclaimer2_risk,
|
||||||
|
R.string.disclaimer2_risk
|
||||||
|
};
|
||||||
|
//int bg = ContextCompat.getColor(this, backgroundRes[level]);
|
||||||
|
scoreBox.setBackgroundResource(backgroundRes[level]);
|
||||||
|
heartScoreDesc.setText(getText(descRes[level]));
|
||||||
|
heartScoreTitle.setText(getText(titleRes[level]));
|
||||||
|
whatNow.setText(getText(whatRes[level]));
|
||||||
|
whatNow.setMovementMethod(LinkMovementMethod.getInstance()); // clickable links (mailto)
|
||||||
|
|
||||||
|
Date timeStamp = new java.util.Date(measurement.startTime * 1000);
|
||||||
|
String dateText = "Date: " + mDateFormat.format(timeStamp) + " at " + mTimeFormat.format(timeStamp);
|
||||||
|
date.setText(dateText);
|
||||||
|
date2.setText(dateText);
|
||||||
|
|
||||||
|
measurementIdText.setVisibility(View.GONE);
|
||||||
|
|
||||||
|
if(result == null) {
|
||||||
|
resultImage.setImageResource(R.drawable.offline);
|
||||||
|
resultImage.invalidate();
|
||||||
|
heartRate.setText("Heart rate: -");
|
||||||
|
heartRateVariability.setText("");
|
||||||
|
heartScore.setText("HeartShield Score: ?");
|
||||||
|
whatNow.setText(getText(R.string.whatnow_offline));
|
||||||
|
details.setVisibility(View.GONE);
|
||||||
|
disclaimer.setVisibility(View.GONE);
|
||||||
|
return;
|
||||||
|
} else if(result.status == MeasurementResult.Status.UNCLASSIFIED || result.status == MeasurementResult.Status.RECORDED) {
|
||||||
|
int img = result.status == MeasurementResult.Status.UNCLASSIFIED ? R.drawable.help_small : R.drawable.heart_only;
|
||||||
|
resultImage.setImageResource(img); // should be consistent with ResultListActivity -> ResultLineAdapter
|
||||||
|
resultImage.invalidate();
|
||||||
|
heartRate.setText("Heart rate: -");
|
||||||
|
heartRateVariability.setText("");
|
||||||
|
heartScore.setText("HeartShield Score: ?");
|
||||||
|
details.setVisibility(View.VISIBLE);
|
||||||
|
showChart(result);
|
||||||
|
disclaimer.setVisibility(View.GONE);
|
||||||
|
if(result.status == MeasurementResult.Status.RECORDED)
|
||||||
|
whatNow.setText(result.reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.setVisibility(View.VISIBLE);
|
||||||
|
disclaimer.setVisibility(View.VISIBLE);
|
||||||
|
|
||||||
|
/*
|
||||||
|
final int GREENISH = Color.rgb(0, 204, 0);
|
||||||
|
final int REDDISH = Color.rgb(204, 96, 0);
|
||||||
|
*/
|
||||||
|
|
||||||
|
level = measurement.getRiskLevel();
|
||||||
|
|
||||||
|
scoreBox.setBackgroundResource(backgroundRes[level]);
|
||||||
|
heartScoreDesc.setText(getText(descRes[level]));
|
||||||
|
heartScoreTitle.setText(getText(titleRes[level]));
|
||||||
|
whatNow.setText(getText(whatRes[level]));
|
||||||
|
|
||||||
|
disclaimer.setText(getText(disclaimerRes[level]));
|
||||||
|
|
||||||
|
resultImage.setImageResource(heartIconRes[level]);
|
||||||
|
resultImage.invalidate();
|
||||||
|
heartScore.setText("HeartShield Score: " + Integer.toString((int) (100.0 * result.pred)));
|
||||||
|
//heartScore.setTextColor(result.isHealthy() ? GREENISH : REDDISH);
|
||||||
|
|
||||||
|
if(result.token != null) {
|
||||||
|
measurementIdText.setVisibility(View.VISIBLE);
|
||||||
|
measurementIdText.setText(String.format(Locale.getDefault(), "Measurement ID: %s", result.token));
|
||||||
|
}
|
||||||
|
|
||||||
|
heartRate.setText(String.format(Locale.getDefault(), "Heart rate: %d", (int) result.getHeartRate()));
|
||||||
|
|
||||||
|
heartRateVariability.setText(String.format(Locale.getDefault(), HRV_TEMPLATE,
|
||||||
|
result.hrvTimeDomain.sdnn,
|
||||||
|
result.hrvTimeDomain.rmssd,
|
||||||
|
result.hrvTimeDomain.pnn50,
|
||||||
|
result.hrvTimeDomain.hrv_tri,
|
||||||
|
decRound(result.hrvFrequencyDomain.total_power, 1),
|
||||||
|
decRound(result.hrvFrequencyDomain.lf, 1),
|
||||||
|
decRound(result.hrvFrequencyDomain.hf, 1),
|
||||||
|
result.hrvFrequencyDomain.lf / result.hrvFrequencyDomain.hf));
|
||||||
|
|
||||||
|
showChart(result);
|
||||||
|
|
||||||
|
Bitmap reportImage = mDataManager.getReportImage(measurement.getId());
|
||||||
|
if(reportImage != null)
|
||||||
|
riskReportImage.setImageBitmap(reportImage);
|
||||||
|
else
|
||||||
|
riskReportImage.setImageResource(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showChart(MeasurementResult result) {
|
||||||
|
LineChartWrapper chart = new LineChartWrapper((LineChart) findViewById(R.id.chart), LINE_WIDTH, LINE_COLOR);
|
||||||
|
|
||||||
|
double[] fakeTimes = DVec.linspace(0, LineChartWrapper.WINDOW_SIZE_SECS, result.filtered.length);
|
||||||
|
|
||||||
|
double[] adjusted = result.filtered;
|
||||||
|
adjusted = DVec.mul(adjusted, -1.0);
|
||||||
|
adjusted = DVec.sub(adjusted, DVec.min(adjusted));
|
||||||
|
adjusted = DVec.mul(adjusted, 0.9 / DVec.max(adjusted));
|
||||||
|
adjusted = DVec.add(adjusted, 0.1);
|
||||||
|
for(int i = 0; i < result.idx.length; i++)
|
||||||
|
adjusted[result.idx[i]] = 0.0;
|
||||||
|
|
||||||
|
chart.replaceData(fakeTimes, adjusted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String HRV_TEMPLATE = "" +
|
||||||
|
"SDNN: %.0f ms\n" +
|
||||||
|
"RMSSD: %.0f ms\n" +
|
||||||
|
"PNN50: %.0f %%\n" +
|
||||||
|
"HRV triangular index: %.1f\n" +
|
||||||
|
"total power: %.0f ms^2\n" +
|
||||||
|
"LF: %.0f ms^2\n" +
|
||||||
|
"HF: %.0f ms^2\n" +
|
||||||
|
"LF/HF ratio: %.2f\n";
|
||||||
|
|
||||||
|
private static double decRound(double d, int digits) {
|
||||||
|
double fac = Math.pow(10.0, digits);
|
||||||
|
return Math.round(d / fac) * fac;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/src/main/java/net/heartshield/prevent/ResultLineAdapter.java
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.support.annotation.NonNull;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.text.format.DateFormat;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ArrayAdapter;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import net.heartshield.data.MeasurementSummary;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-20
|
||||||
|
*/
|
||||||
|
public class ResultLineAdapter extends ArrayAdapter<MeasurementSummary> {
|
||||||
|
private List<MeasurementSummary> mEntries;
|
||||||
|
private LayoutInflater mInflater;
|
||||||
|
private java.text.DateFormat mDateFormat;
|
||||||
|
private java.text.DateFormat mTimeFormat;
|
||||||
|
|
||||||
|
public ResultLineAdapter(@NonNull Context context, MeasurementSummary[] entries) {
|
||||||
|
super(context, R.layout.rowlayout_result, entries);
|
||||||
|
|
||||||
|
mEntries = new ArrayList<>(Arrays.asList(Arrays.copyOf(entries, entries.length)));
|
||||||
|
Collections.reverse(mEntries);
|
||||||
|
|
||||||
|
mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||||
|
mDateFormat = DateFormat.getDateFormat(context.getApplicationContext());
|
||||||
|
mTimeFormat = DateFormat.getTimeFormat(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ViewHolder {
|
||||||
|
public ImageView icon;
|
||||||
|
public TextView heartScore;
|
||||||
|
public TextView firstLine;
|
||||||
|
public TextView secondLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only call this from UI thread!
|
||||||
|
public void add(MeasurementSummary measurement) {
|
||||||
|
mEntries.add(0, measurement);
|
||||||
|
//insert(measurement, 0);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// only call this from UI thread!
|
||||||
|
public void update(MeasurementSummary measurement) {
|
||||||
|
int i = mEntries.indexOf(measurement);
|
||||||
|
if(i == -1)
|
||||||
|
throw new IllegalArgumentException("measurement " + measurement.getId().toString() + " not found");
|
||||||
|
mEntries.set(i, measurement);
|
||||||
|
//insert(measurement, 0);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public MeasurementSummary getItem(int position) {
|
||||||
|
return mEntries.get(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
|
||||||
|
View rowView = convertView;
|
||||||
|
|
||||||
|
//View rowView = mInflater.inflate(R.layout.rowlayout_result, parent, false);
|
||||||
|
|
||||||
|
int[] heartIconRes = {
|
||||||
|
R.drawable.help_small, // should be consistent with ResultListActivity -> ResultLineAdapter
|
||||||
|
R.drawable.heart_ok,
|
||||||
|
R.drawable.heart_only,
|
||||||
|
R.drawable.heart_nok
|
||||||
|
};
|
||||||
|
|
||||||
|
if(rowView == null) {
|
||||||
|
//rowView = mInflater.inflate(R.layout.rowlayout_result, null); // NO!
|
||||||
|
|
||||||
|
//Log.i("ResultLineAdapter", "inflating position=" + position);
|
||||||
|
rowView = mInflater.inflate(R.layout.rowlayout_result, parent, false);
|
||||||
|
|
||||||
|
ViewHolder viewHolder = new ViewHolder();
|
||||||
|
viewHolder.icon = (ImageView) rowView.findViewById(R.id.icon);
|
||||||
|
viewHolder.heartScore = (TextView) rowView.findViewById(R.id.heartScore);
|
||||||
|
viewHolder.firstLine = (TextView) rowView.findViewById(R.id.firstLine);
|
||||||
|
viewHolder.secondLine = (TextView) rowView.findViewById(R.id.secondLine);
|
||||||
|
rowView.setTag(viewHolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewHolder viewHolder = (ViewHolder) rowView.getTag();
|
||||||
|
|
||||||
|
MeasurementSummary entry = mEntries.get(position);
|
||||||
|
Date timeStamp = new java.util.Date(entry.startTime * 1000);
|
||||||
|
|
||||||
|
// performance note: make sure the image is small enough for quick rendering
|
||||||
|
// scroll performance will suffer otherwise.
|
||||||
|
|
||||||
|
viewHolder.secondLine.setText("#" + entry.number + " on " + mDateFormat.format(timeStamp) + " at " + mTimeFormat.format(timeStamp));
|
||||||
|
|
||||||
|
viewHolder.icon.setImageResource(heartIconRes[entry.riskLevel]);
|
||||||
|
|
||||||
|
if(entry.status == MeasurementSummary.Status.OK) {
|
||||||
|
String score = Integer.toString(entry.heartScore);
|
||||||
|
viewHolder.heartScore.setText(score);
|
||||||
|
viewHolder.firstLine.setText(entry.bpm + " bpm");
|
||||||
|
} else if(entry.status == MeasurementSummary.Status.UNCLASSIFIED) {
|
||||||
|
// should be consistent with ResultActivity UNCLASSIFIED
|
||||||
|
viewHolder.heartScore.setText(" -");
|
||||||
|
viewHolder.firstLine.setText(" - bpm");
|
||||||
|
} else if(entry.status == MeasurementSummary.Status.OFFLINE) {
|
||||||
|
viewHolder.icon.setImageResource(R.drawable.offline); // nice-to: improve icons
|
||||||
|
viewHolder.heartScore.setText(" -");
|
||||||
|
viewHolder.firstLine.setText(" - bpm");
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowView;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.ListActivity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ListView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import net.heartshield.data.DataManagerFactory;
|
||||||
|
import net.heartshield.data.IMeasurementDataManager;
|
||||||
|
import net.heartshield.data.Measurement;
|
||||||
|
import net.heartshield.data.MeasurementDataManager;
|
||||||
|
import net.heartshield.data.MeasurementSummary;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a list of previous measurements and allows to open them for viewing/editing.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-20
|
||||||
|
*/
|
||||||
|
public class ResultListActivity extends ListActivity {
|
||||||
|
private static final String TAG = "ResultListActivity";
|
||||||
|
|
||||||
|
private IMeasurementDataManager mDataManager;
|
||||||
|
private ResultLineAdapter mAdapter;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
//setContentView(R.layout.activity_result_list);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
// the following initialization is in onResume() and not in onCreate() because a testing mode
|
||||||
|
// requires on-the-fly changes to MeasurementDataManager
|
||||||
|
|
||||||
|
mDataManager = DataManagerFactory.getInstance().getDataManager(this);
|
||||||
|
mDataManager.startBackgroundThread();
|
||||||
|
mDataManager.startBackgroundSync();
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
// testing only
|
||||||
|
MeasurementSummary[] entries = new MeasurementSummary[]{
|
||||||
|
new MeasurementSummary(1492694754, "C648B4CC", MeasurementSummary.Status.OK, true, 34, 86),
|
||||||
|
new MeasurementSummary(1492664750, "C648B4CC", MeasurementSummary.Status.OK, false, 74, 123)
|
||||||
|
};
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
mAdapter = new ResultLineAdapter(this, mDataManager.list());
|
||||||
|
|
||||||
|
mDataManager.setSummaryListener(new IMeasurementDataManager.MeasurementSummaryListener() {
|
||||||
|
@Override
|
||||||
|
public void onMeasurementCreated(final Measurement measurement) {
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mAdapter.add(measurement.getSummary());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMeasurementUpdated(final Measurement measurement) {
|
||||||
|
runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mAdapter.update(measurement.getSummary());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setListAdapter(mAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
mDataManager.stopBackgroundThread();
|
||||||
|
mDataManager.setSummaryListener(null); // stop receiving updates when not active
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onListItemClick(ListView l, View v, int position, long id) {
|
||||||
|
MeasurementSummary item = (MeasurementSummary) getListAdapter().getItem(position);
|
||||||
|
//Toast.makeText(this, item.getId().toString() + " selected", Toast.LENGTH_SHORT).show();
|
||||||
|
|
||||||
|
/*
|
||||||
|
if(item.status == MeasurementSummary.Status.OFFLINE) {
|
||||||
|
Measurement measurement = null;
|
||||||
|
try {
|
||||||
|
measurement = mDataManager.retrieve(item.getId());
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
Toast.makeText(ResultListActivity.this, "Failed to open result file! " + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.makeText(ResultListActivity.this, "Sending measurement again...", Toast.LENGTH_LONG).show();
|
||||||
|
|
||||||
|
mDataManager.update(measurement, new IMeasurementDataManager.MeasurementResultListener() {
|
||||||
|
@Override
|
||||||
|
public void onResult(Measurement measurement) {
|
||||||
|
//assert(measurement.result != null);
|
||||||
|
|
||||||
|
// successful result from server
|
||||||
|
Intent intent = new Intent(ResultListActivity.this, ResultActivity.class);
|
||||||
|
intent.putExtra("measurementId", measurement.getId());
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Measurement measurement, IMeasurementDataManager.ErrorCode code, String message) {
|
||||||
|
// some error saving the stuff
|
||||||
|
Toast.makeText(ResultListActivity.this, "Failed to get result: " + code + " " + message, Toast.LENGTH_LONG).show();
|
||||||
|
Log.e(TAG, "Failed to get result: " + code + " " + message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {*/
|
||||||
|
Intent intent = new Intent(ResultListActivity.this, ResultActivity.class);
|
||||||
|
intent.putExtra("measurementId", item.getId());
|
||||||
|
startActivity(intent);
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/src/main/java/net/heartshield/prevent/SquareImageButton.java
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.res.TypedArray;
|
||||||
|
import android.graphics.drawable.ColorDrawable;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.support.v4.content.ContextCompat;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ImageButton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ImageButton that always renders as a square.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-01
|
||||||
|
*/
|
||||||
|
public class SquareImageButton extends android.support.v7.widget.AppCompatImageButton {
|
||||||
|
private static final String TAG = "SquareImageButton";
|
||||||
|
|
||||||
|
private int mHighlightColor;
|
||||||
|
private int mRegularColor;
|
||||||
|
private boolean mSquare;
|
||||||
|
|
||||||
|
public SquareImageButton(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
public SquareImageButton(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs); init(context, attrs);
|
||||||
|
}
|
||||||
|
public SquareImageButton(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
init(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init(Context context, AttributeSet attrs) {
|
||||||
|
|
||||||
|
mHighlightColor = ContextCompat.getColor(context, R.color.square_image_background_highlight);
|
||||||
|
|
||||||
|
TypedArray a = context.getTheme().obtainStyledAttributes(
|
||||||
|
attrs,
|
||||||
|
R.styleable.SquareImageButton,
|
||||||
|
0, 0);
|
||||||
|
mSquare = a.getBoolean(R.styleable.SquareImageButton_square, true);
|
||||||
|
|
||||||
|
int regularColorFallback = ContextCompat.getColor(context, R.color.square_image_background_regular);
|
||||||
|
|
||||||
|
int color = regularColorFallback;
|
||||||
|
Drawable background = this.getBackground();
|
||||||
|
if (background instanceof ColorDrawable)
|
||||||
|
color = ((ColorDrawable) background).getColor();
|
||||||
|
|
||||||
|
mRegularColor = color;
|
||||||
|
|
||||||
|
setOnTouchListener(new OnTouchListener() {
|
||||||
|
@Override
|
||||||
|
public boolean onTouch(View v, MotionEvent event) {
|
||||||
|
switch (event.getAction()) {
|
||||||
|
case MotionEvent.ACTION_DOWN: {
|
||||||
|
Log.i(TAG, "ACTION_DOWN");
|
||||||
|
ImageButton view = (ImageButton ) v;
|
||||||
|
//view.setBackgroundColor(0x80800000);
|
||||||
|
view.setBackgroundColor(mHighlightColor);
|
||||||
|
|
||||||
|
// ContextCompat.getColor(context, R.color.my_color)
|
||||||
|
//view.getBackground().setColorFilter(0x77800000, PorterDuff.Mode.SRC_ATOP);
|
||||||
|
v.invalidate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
Log.i(TAG, "ACTION_UP");
|
||||||
|
|
||||||
|
// Your action here on button click
|
||||||
|
callOnClick(); // either this, or return false (which has other side effects)
|
||||||
|
|
||||||
|
// fall through to clear color filter
|
||||||
|
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_CANCEL: {
|
||||||
|
ImageButton view = (ImageButton) v;
|
||||||
|
view.setBackgroundColor(mRegularColor);
|
||||||
|
//view.getBackground().clearColorFilter();
|
||||||
|
view.invalidate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
//return false; // make sure onClick() gets fired as well // avoids ACTION_UP for not-listened onClick()???
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
|
if(!mSquare) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
|
||||||
|
final int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
|
||||||
|
//final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
|
||||||
|
final int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
|
||||||
|
|
||||||
|
if(widthSpecSize < heightSpecSize)
|
||||||
|
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
|
||||||
|
else
|
||||||
|
super.onMeasure(heightMeasureSpec, heightMeasureSpec);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/src/main/java/net/heartshield/prevent/TimeActivity.java
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import net.heartshield.data.UserMeta;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-26
|
||||||
|
*/
|
||||||
|
public class TimeActivity extends Activity {
|
||||||
|
private UserMeta mUser;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_s03_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
mUser = (UserMeta) getIntent().getSerializableExtra("meta");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void next(View view) {
|
||||||
|
Intent intent = new Intent(TimeActivity.this, InfoActivity.class);
|
||||||
|
intent.putExtra("meta", mUser);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package net.heartshield.prevent;
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.res.TypedArray;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ImageButton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ImageButton that always renders as a square.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-04-01
|
||||||
|
*/
|
||||||
|
public class ToggleImageButton extends android.support.v7.widget.AppCompatImageButton {
|
||||||
|
private static final String TAG = "ToggleImageButton";
|
||||||
|
|
||||||
|
private int mSrcDisabled;
|
||||||
|
private int mSrcEnabled;
|
||||||
|
private boolean mPressed;
|
||||||
|
|
||||||
|
public ToggleImageButton(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
public ToggleImageButton(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs); init(context, attrs);
|
||||||
|
}
|
||||||
|
public ToggleImageButton(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
init(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init(Context context, AttributeSet attrs) {
|
||||||
|
TypedArray a = context.getTheme().obtainStyledAttributes(
|
||||||
|
attrs,
|
||||||
|
R.styleable.ToggleImageButton,
|
||||||
|
0, 0);
|
||||||
|
mSrcDisabled = a.getResourceId(R.styleable.ToggleImageButton_srcDisabled, 0);
|
||||||
|
mSrcEnabled = a.getResourceId(R.styleable.ToggleImageButton_srcEnabled, 0);
|
||||||
|
if(mSrcDisabled == 0 || mSrcEnabled == 0)
|
||||||
|
throw new IllegalArgumentException("must define srcDisabled and srcEnabled image resources");
|
||||||
|
|
||||||
|
mPressed = false;
|
||||||
|
setImageResource(mSrcDisabled);
|
||||||
|
|
||||||
|
|
||||||
|
setOnTouchListener(new OnTouchListener() {
|
||||||
|
@Override
|
||||||
|
public boolean onTouch(View v, MotionEvent event) {
|
||||||
|
switch (event.getAction()) {
|
||||||
|
case MotionEvent.ACTION_DOWN: {
|
||||||
|
Log.i(TAG, "ACTION_DOWN");
|
||||||
|
ImageButton view = (ImageButton) v;
|
||||||
|
//view.setBackgroundColor(0x80800000);
|
||||||
|
//view.setBackgroundColor(mHighlightColor);
|
||||||
|
|
||||||
|
// ContextCompat.getColor(context, R.color.my_color)
|
||||||
|
//view.getBackground().setColorFilter(0x77800000, PorterDuff.Mode.SRC_ATOP);
|
||||||
|
//v.invalidate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
Log.i(TAG, "ACTION_UP");
|
||||||
|
|
||||||
|
Log.i(TAG, "setting toggle button to state=" + Boolean.toString(!mPressed));
|
||||||
|
setPressed(!mPressed);
|
||||||
|
|
||||||
|
// Your action here on button click
|
||||||
|
callOnClick(); // either this, or return false (which has other side effects)
|
||||||
|
|
||||||
|
// fall through to clear color filter
|
||||||
|
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_CANCEL: {
|
||||||
|
ImageButton view = (ImageButton) v;
|
||||||
|
//view.setBackgroundColor(mRegularColor);
|
||||||
|
//view.getBackground().clearColorFilter();
|
||||||
|
//view.invalidate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
//return false; // make sure onClick() gets fired as well // avoids ACTION_UP for not-listened onClick()???
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPressed(boolean pressed) {
|
||||||
|
setImageResource(pressed ? mSrcEnabled : mSrcDisabled);
|
||||||
|
postInvalidate();
|
||||||
|
mPressed = pressed;
|
||||||
|
}
|
||||||
|
public boolean getPressed() { return mPressed; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.hardware.Sensor;
|
||||||
|
import android.hardware.SensorEvent;
|
||||||
|
import android.hardware.SensorEventListener;
|
||||||
|
import android.hardware.SensorManager;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.os.Looper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accelerometer time series signal capture.
|
||||||
|
*
|
||||||
|
* Events are delivered to the main thread, but data handling occurs in a separate thread.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-03-21
|
||||||
|
*/
|
||||||
|
public class AccelerationSensor implements ISensor, SensorEventListener {
|
||||||
|
private SensorManager mSensorManager;
|
||||||
|
private Sensor mSensor;
|
||||||
|
private AccelerationHandler mBackgroundHandlerThread;
|
||||||
|
private Handler mBackgroundHandler;
|
||||||
|
private State mState;
|
||||||
|
private final Object mStateLock = new Object();
|
||||||
|
|
||||||
|
private long mPrevTimestamp; // in nanoseconds
|
||||||
|
private long mMinDataEventTime; // in nanoseconds
|
||||||
|
private int mPrevOffset;
|
||||||
|
|
||||||
|
private Handler mHandler;
|
||||||
|
private Double mFps;
|
||||||
|
private SensorData mData;
|
||||||
|
private ISensor.Listener mListener = null;
|
||||||
|
|
||||||
|
private static final int NDIM = 3; // x,y,z channels
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param context Android Context used to get the sensor service
|
||||||
|
* @param maxDataEventRate maximum rate of onData() events emitted per second
|
||||||
|
*/
|
||||||
|
public AccelerationSensor(Context context, double maxDataEventRate) {
|
||||||
|
mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
|
||||||
|
mSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
|
||||||
|
mFps = 1e6 / (double) mSensor.getMinDelay();
|
||||||
|
mMinDataEventTime = (long) (1e9 / maxDataEventRate);
|
||||||
|
|
||||||
|
mBackgroundHandlerThread = new AccelerationHandler();
|
||||||
|
//mBackgroundHandler = new Handler(mBackgroundHandlerThread.getLooper());
|
||||||
|
mBackgroundHandlerThread.start();
|
||||||
|
|
||||||
|
mHandler = new Handler(Looper.getMainLooper());
|
||||||
|
mData = new SensorData(NDIM);
|
||||||
|
mState = State.STOPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strange that this is needed. No Looper is found if we try this:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* // WRONG:
|
||||||
|
* mBackgroundHandlerThread = new HandlerThread("AccelerationSensor");
|
||||||
|
* mBackgroundHandler = new Handler(mBackgroundHandlerThread.getLooper());
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
private class AccelerationHandler extends Thread {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Looper.prepare();
|
||||||
|
mBackgroundHandler = new Handler();
|
||||||
|
Looper.loop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitOnState(final ISensor.State newState) {
|
||||||
|
State oldState;
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
oldState = mState;
|
||||||
|
mState = newState;
|
||||||
|
}
|
||||||
|
if(mListener == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if(!newState.equals(oldState)) {
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onState(newState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long mRefTimestamp;
|
||||||
|
private long mStartTimeNanos;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSensorChanged(SensorEvent event) {
|
||||||
|
State state;
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
// ignore possible spurious callbacks still in the queue
|
||||||
|
if (mState == State.STOPPED)
|
||||||
|
return;
|
||||||
|
state = mState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(state == State.STARTING) {
|
||||||
|
mRefTimestamp = event.timestamp;
|
||||||
|
mStartTimeNanos = System.nanoTime();
|
||||||
|
emitOnState(State.RUNNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
mData.addSample(new double[]{
|
||||||
|
// convert to nanoTime as best as we can
|
||||||
|
((double) (event.timestamp - mRefTimestamp + mStartTimeNanos)) / 1e9,
|
||||||
|
event.values[0],
|
||||||
|
event.values[1],
|
||||||
|
event.values[2]
|
||||||
|
});
|
||||||
|
|
||||||
|
if((event.timestamp - mPrevTimestamp) > mMinDataEventTime) {
|
||||||
|
final int offset = mPrevOffset;
|
||||||
|
if(mListener != null) {
|
||||||
|
final int length = mData.length() - offset;
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onData(offset, length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mPrevTimestamp = event.timestamp;
|
||||||
|
mPrevOffset = mData.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAccuracyChanged(Sensor sensor, int accuracy) {}
|
||||||
|
|
||||||
|
/////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal number of sample values per second.
|
||||||
|
* Only valid after the first call to {@link ISensor#start}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public double getFps() { return mFps; }
|
||||||
|
|
||||||
|
/** start measurement, resetting sensor data */
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
if(mState != State.STOPPED && mState != State.FAILED)
|
||||||
|
throw new IllegalStateException("start() called in invalid state: " + mState.toString());
|
||||||
|
mState = State.STARTING;
|
||||||
|
}
|
||||||
|
|
||||||
|
mData.clear();
|
||||||
|
mPrevTimestamp = 0;
|
||||||
|
mPrevOffset = 0;
|
||||||
|
|
||||||
|
mRefTimestamp = 0;
|
||||||
|
mStartTimeNanos = 0;
|
||||||
|
|
||||||
|
final int maxReportLatencyUs = (int) (mMinDataEventTime / 1000); // hopefully save some CPU
|
||||||
|
|
||||||
|
mSensorManager.registerListener(this, mSensor, SensorManager.SENSOR_DELAY_FASTEST, maxReportLatencyUs, mBackgroundHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stop measurement, keeping sensor data */
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
mSensorManager.unregisterListener(this);
|
||||||
|
emitOnState(State.STOPPED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series of each dimension: e.g. {{t1, t2, ...}, {x1, x2, ...}}
|
||||||
|
* Deep copies are returned.
|
||||||
|
*
|
||||||
|
* @return sensor data recorded since {@link ISensor#start} was called
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<double[]> getSamples() { return mData.getSamples(); }
|
||||||
|
|
||||||
|
/** @return number of samples */
|
||||||
|
@Override
|
||||||
|
public int length() { return mData.length(); }
|
||||||
|
|
||||||
|
/** events are delivered to the main thread, but pixel handling occurs in a separate thread */
|
||||||
|
@Override
|
||||||
|
public void setListener(ISensor.Listener listener) { mListener = listener; }
|
||||||
|
|
||||||
|
/** obtain details after FAILED state */
|
||||||
|
@Override
|
||||||
|
public Exception getError() { return null; }
|
||||||
|
}
|
||||||
519
app/src/main/java/net/heartshield/sensors/AudioSensor.java
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import android.media.AudioFormat;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.AudioRecord;
|
||||||
|
import android.media.AudioTimestamp;
|
||||||
|
import android.media.AudioTrack;
|
||||||
|
import android.media.MediaRecorder;
|
||||||
|
import android.media.MediaSyncEvent;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.os.Process;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import net.heartshield.data.WavData;
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio time series signal capture.
|
||||||
|
*
|
||||||
|
* AudioTimestamp needs API level 19.
|
||||||
|
*
|
||||||
|
* Events are delivered to the main thread, but data handling occurs in a separate thread.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-03-21
|
||||||
|
*/
|
||||||
|
public class AudioSensor implements IAudioSensor {
|
||||||
|
private static final String TAG = "AudioSensor";
|
||||||
|
|
||||||
|
private static final int READ_TO_BUF_SIZE_RATIO = 2; // in code, always refer to mReadToBufSizeRatio which is testable
|
||||||
|
private int mReadToBufSizeRatio; // for testing
|
||||||
|
|
||||||
|
private AudioRecorder mRecorder;
|
||||||
|
private State mState;
|
||||||
|
private final Object mStateLock = new Object();
|
||||||
|
|
||||||
|
private double mPrevTimestamp; // in nanoseconds
|
||||||
|
private double mMinDataEventTime; // in seconds // TODO rename to *Sec
|
||||||
|
private int mPrevOffset;
|
||||||
|
|
||||||
|
private Handler mHandler;
|
||||||
|
private Double mFps;
|
||||||
|
private SensorDataPCM mDataPCM;
|
||||||
|
private ISensor.Listener mListener = null;
|
||||||
|
private Exception mError = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param maxDataEventRate maximum rate of onData() events emitted per second
|
||||||
|
*/
|
||||||
|
public AudioSensor(double maxDataEventRate) {
|
||||||
|
|
||||||
|
mMinDataEventTime = 1.0 / maxDataEventRate;
|
||||||
|
mReadToBufSizeRatio = READ_TO_BUF_SIZE_RATIO;
|
||||||
|
|
||||||
|
mHandler = new Handler(Looper.getMainLooper());
|
||||||
|
mDataPCM = new SensorDataPCM();
|
||||||
|
mState = State.STOPPED;
|
||||||
|
|
||||||
|
initSamplingRate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public State getState() { return mState; }
|
||||||
|
|
||||||
|
/** for testing only! must be set before calling start() */
|
||||||
|
void setReadToBufSizeRatio(int ratio) {
|
||||||
|
mReadToBufSizeRatio = ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int AUDIO_SOURCE = MediaRecorder.AudioSource.MIC;
|
||||||
|
private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO;
|
||||||
|
private static final int CHANNEL_NUM = 1;
|
||||||
|
private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
|
||||||
|
private static final int AUDIO_FORMAT_BYTES = 2;
|
||||||
|
private static final int STRIDE = CHANNEL_NUM * AUDIO_FORMAT_BYTES;
|
||||||
|
private int mSamplingRate;
|
||||||
|
private int mMinBufferSize;
|
||||||
|
private int mBufferSize;
|
||||||
|
private int mReadSize;
|
||||||
|
private long mReadSpacingNanos; // usual time between two read() calls
|
||||||
|
private long mReadSpacingMaxNanos; // maximum time elapsed between two read() calls before we'll start dropping frames
|
||||||
|
|
||||||
|
public class AudioException extends RuntimeException {
|
||||||
|
public AudioException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
public AudioException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AudioLagException extends AudioException {
|
||||||
|
public AudioLagException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initSamplingRate() {
|
||||||
|
// see Android CDD (compatibility definition document) at http://source.android.com/compatibility/6.0/android-6.0-cdd.html
|
||||||
|
// current version for Android 7.1 at https://static.googleusercontent.com/media/source.android.com/en//compatibility/android-cdd.pdf
|
||||||
|
// "Device implementations that declare android.hardware.microphone MUST allow capture of raw audio content with [...] Sampling rates : 8000, 11025, 16000, 44100"
|
||||||
|
// "SHOULD allow capture of raw audio content with [...] Sampling rates : 22050, 48000"
|
||||||
|
final int[] RATES = new int[]{48000, 44100};
|
||||||
|
for(int rate : RATES) {
|
||||||
|
int bufferSize = AudioRecord.getMinBufferSize(rate, CHANNEL_CONFIG, AUDIO_FORMAT);
|
||||||
|
// rate is supported if bufferSize is valid
|
||||||
|
if(bufferSize > 0) {
|
||||||
|
mSamplingRate = rate;
|
||||||
|
mFps = (double) rate;
|
||||||
|
mMinBufferSize = bufferSize;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("could not find a valid audio sampling rate");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initBufferSize() {
|
||||||
|
if(mMinBufferSize == 0)
|
||||||
|
throw new IllegalStateException("call initSamplingRate() first");
|
||||||
|
|
||||||
|
int bufSizePerSec = AUDIO_FORMAT_BYTES * mSamplingRate * CHANNEL_NUM;
|
||||||
|
int idealBufferSize = (int) (bufSizePerSec * mMinDataEventTime * mReadToBufSizeRatio); // buf itself is bigger to accomodate several smaller reads
|
||||||
|
|
||||||
|
// size buffer so one time interval between onData() events will fit.
|
||||||
|
// since we have a background handler reading data, we can probably do with much less.
|
||||||
|
mBufferSize = Math.max(mMinBufferSize, idealBufferSize);
|
||||||
|
|
||||||
|
mReadSize = mBufferSize / mReadToBufSizeRatio;
|
||||||
|
mReadSpacingNanos = (long) (1e9 * mReadSize / bufSizePerSec);
|
||||||
|
mReadSpacingMaxNanos = (long) (1e9 * mBufferSize / bufSizePerSec);
|
||||||
|
|
||||||
|
Log.i(TAG, "buffer size per sec: " + bufSizePerSec);
|
||||||
|
Log.i(TAG, "min buffer size: " + mMinBufferSize);
|
||||||
|
Log.i(TAG, "chosen buffer size: " + mBufferSize);
|
||||||
|
Log.i(TAG, "chosen read size: " + mReadSize);
|
||||||
|
Log.i(TAG, "chosen read spacing: " + mReadSpacingNanos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int PLAYBACK_TIME_IN_MS = 2000;
|
||||||
|
public static final int START_TIME_MS = PLAYBACK_TIME_IN_MS + 500;
|
||||||
|
|
||||||
|
private class AudioRecorder extends Thread {
|
||||||
|
boolean mStopped = false;
|
||||||
|
|
||||||
|
// see Android CDD: "The device MUST allow playback of raw audio content with the following characteristics: [...] Sampling rates : 8000"
|
||||||
|
final int PLAYBACK_SAMPLE_RATE = 8000;
|
||||||
|
// TODO: rename 2x below to Secs
|
||||||
|
private double mNanoTime; // recording time as far as samples have progressed
|
||||||
|
private double mNanoTimeStep; // nanoseconds between each sample
|
||||||
|
|
||||||
|
private AudioRecord mRecord;
|
||||||
|
|
||||||
|
private AudioTrack createSilentTrack() {
|
||||||
|
final int frameCount = PLAYBACK_TIME_IN_MS * PLAYBACK_SAMPLE_RATE / 1000;
|
||||||
|
final int frameSize = 1;
|
||||||
|
final int sampleCount = frameCount * 1; // * format.getChannelCount(); // getChannelCount() would need API level 23
|
||||||
|
final int bufSize = frameSize * sampleCount;
|
||||||
|
|
||||||
|
AudioTrack track = new AudioTrack(AudioManager.STREAM_NOTIFICATION, PLAYBACK_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_8BIT, bufSize, AudioTrack.MODE_STATIC);
|
||||||
|
// AudioTrack(int streamType, int sampleRateInHz, int channelConfig, int audioFormat, int bufferSizeInBytes, int mode)
|
||||||
|
/*
|
||||||
|
new AudioTrack.Builder()
|
||||||
|
.setAudioFormat(format)
|
||||||
|
.setBufferSizeInBytes(frameCount * frameSize)
|
||||||
|
.setTransferMode(AudioTrack.MODE_STATIC)
|
||||||
|
.build();
|
||||||
|
*/
|
||||||
|
// create frame array and write it
|
||||||
|
|
||||||
|
byte[] vab = new byte[bufSize];
|
||||||
|
//Arrays.fill(vab, -128);
|
||||||
|
for(int i = 0; i < vab.length; i++)
|
||||||
|
vab[i] = -128;
|
||||||
|
track.write(vab, 0 /* offsetInBytes */, vab.length); // copies audio data for later playback (static buffer mode)
|
||||||
|
|
||||||
|
track.setNotificationMarkerPosition(frameCount-1);
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
runRecording();
|
||||||
|
} catch(Throwable t) {
|
||||||
|
mError = new AudioException(t);
|
||||||
|
Log.e(TAG, "error during recording", mError);
|
||||||
|
emitOnState(ISensor.State.FAILED);
|
||||||
|
} finally {
|
||||||
|
if(mRecord != null)
|
||||||
|
mRecord.release();
|
||||||
|
mRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runRecording() {
|
||||||
|
Log.d(TAG, "entering AudioRecorder thread");
|
||||||
|
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO);
|
||||||
|
|
||||||
|
initBufferSize();
|
||||||
|
|
||||||
|
/* Play back a short, silent AudioTrack to use MediaSyncEvent to sync to it */
|
||||||
|
|
||||||
|
AudioTrack track = createSilentTrack();
|
||||||
|
final int trackSessionId = track.getAudioSessionId();
|
||||||
|
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocateDirect(mBufferSize);
|
||||||
|
mRecord = new AudioRecord(AUDIO_SOURCE, mSamplingRate, CHANNEL_CONFIG, AUDIO_FORMAT, mBufferSize);
|
||||||
|
|
||||||
|
// 3. create a MediaSyncEvent
|
||||||
|
final int eventType = MediaSyncEvent.SYNC_EVENT_PRESENTATION_COMPLETE;
|
||||||
|
MediaSyncEvent event = MediaSyncEvent.createEvent(eventType)
|
||||||
|
.setAudioSessionId(trackSessionId);
|
||||||
|
|
||||||
|
// the marker for this is set with setNotificationMarkerPosition() in createSilentTrack()
|
||||||
|
track.setPlaybackPositionUpdateListener(new AudioTrack.OnPlaybackPositionUpdateListener() {
|
||||||
|
@Override
|
||||||
|
public void onMarkerReached(AudioTrack track) {
|
||||||
|
Log.i(TAG, "onMarkerReached(), switching to RUNNING state");
|
||||||
|
emitOnState(ISensor.State.RUNNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPeriodicNotification(AudioTrack track) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. now set the AudioTrack playing and start the recording synchronized
|
||||||
|
track.play();
|
||||||
|
|
||||||
|
// this returns immediately, but does not start recording yet
|
||||||
|
Log.d(TAG, "startRecording(event), should return immediately");
|
||||||
|
mRecord.startRecording(event);
|
||||||
|
|
||||||
|
long beforeSleepNanoTime = System.nanoTime();
|
||||||
|
Log.d(TAG, "startRecording(event) returned, beforeSleepNanoTime=" + beforeSleepNanoTime);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(PLAYBACK_TIME_IN_MS / 2); // ensure that audio is playing so we can poll for a timestamp.
|
||||||
|
} catch(InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// best guess for when the recording actually starts in time
|
||||||
|
AudioTimestamp ts = new AudioTimestamp(); // AudioTimestamp needs API level 19
|
||||||
|
boolean startTimeGood = track.getTimestamp(ts);
|
||||||
|
long recordStartNanoTime;
|
||||||
|
if(startTimeGood) {
|
||||||
|
// frameTimeNs should be roughly PLAYBACK_TIME_IN_MS / 2
|
||||||
|
long frameTimeNs = (long) (ts.framePosition * 1e9) / PLAYBACK_SAMPLE_RATE;
|
||||||
|
// recordStartNanoTime should be nanoTime at where the playback will end
|
||||||
|
recordStartNanoTime = ts.nanoTime - frameTimeNs + (long) (PLAYBACK_TIME_IN_MS * 1e6);
|
||||||
|
Log.i(TAG, "got a playback timestamp for framePosition=" + ts.framePosition + " at nanoTime=" + ts.nanoTime);
|
||||||
|
mTsAccuracy = AudioStartTimeAccuracy.MEDIUM;
|
||||||
|
} else {
|
||||||
|
recordStartNanoTime = beforeSleepNanoTime + (long) (PLAYBACK_TIME_IN_MS * 1e6);
|
||||||
|
Log.e(TAG, "AudioTrack.getTimestamp() not supported! Audio timestamps will be inaccurate.");
|
||||||
|
mTsAccuracy = AudioStartTimeAccuracy.LOW;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "recordStartNanoTime=" + recordStartNanoTime + " and System.nanoTime=" + System.nanoTime());
|
||||||
|
|
||||||
|
mNanoTime = recordStartNanoTime / 1e9;
|
||||||
|
mNanoTimeStep = 1e9 / mSamplingRate;
|
||||||
|
|
||||||
|
mPrevTimeNanos = 0;
|
||||||
|
while(!mStopped) {
|
||||||
|
/*
|
||||||
|
* AudioRecord.read() blocks until the specified buffer size has been read in full.
|
||||||
|
*
|
||||||
|
* We can sanity-check that it has not been dropping frames through
|
||||||
|
*
|
||||||
|
* 1) checking the time at which buffers were returned.
|
||||||
|
* Assuming huge CPU lags are the cause of dropping, delivery will not be on time.
|
||||||
|
*
|
||||||
|
* 2) From Android API level 24, using {@link AudioRecord#getTimestamp}
|
||||||
|
*
|
||||||
|
* Alternative approaches could be:
|
||||||
|
*
|
||||||
|
* 3) undocumented, unexposed Android JNI API AudioRecord::getInputFramesLost() -- the type has changed (unsigned int -> uint32_t)
|
||||||
|
*
|
||||||
|
* 4) registering a Listener with AudioRecord.setRecordPositionUpdateListener()
|
||||||
|
* and AudioRecord.setPositionNotificationPeriod()
|
||||||
|
* However, docs note that "It is possible for notifications to be lost if the period is too small."
|
||||||
|
*/
|
||||||
|
|
||||||
|
// note: pulled PCM "will be native endian" -- but we'll write it to WAV, which is little endian
|
||||||
|
// see https://developer.android.com/reference/android/media/AudioRecord.html#read(java.nio.ByteBuffer, int)
|
||||||
|
|
||||||
|
if(!ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN))
|
||||||
|
throw new IllegalStateException("implement handling of big endian");
|
||||||
|
|
||||||
|
int nbytes = mRecord.read(buffer, mReadSize);
|
||||||
|
// TODO: read shorts instead, using read(@NonNull short[] audioData, int offsetInShorts, int sizeInShorts)
|
||||||
|
|
||||||
|
long time = System.nanoTime();
|
||||||
|
if(mPrevTimeNanos != 0) {
|
||||||
|
long dt = time - mPrevTimeNanos;
|
||||||
|
if(dt > mReadSpacingMaxNanos)
|
||||||
|
throw new AudioLagException("audio lag: time between reads was " + dt + " nanos, which is larger than our buffer of " + mReadSpacingNanos + " nanos");
|
||||||
|
Log.d(TAG, "onAudioBuffer received nbytes=" + nbytes + " at t=" + time + ", dt=" + dt);
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "onAudioBuffer received nbytes=" + nbytes + " at t=" + time + " first-time");
|
||||||
|
}
|
||||||
|
mPrevTimeNanos = time;
|
||||||
|
|
||||||
|
if(nbytes < 0)
|
||||||
|
throw new RuntimeException("AudioRecord.read() returned error=" + nbytes);
|
||||||
|
|
||||||
|
onAudioBuffer(buffer, nbytes);
|
||||||
|
}
|
||||||
|
emitOnState(ISensor.State.STOPPED);
|
||||||
|
Log.d(TAG, "exiting AudioRecorder thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long mPrevTimeNanos = 0;
|
||||||
|
|
||||||
|
/** even better guess for when the recording actually starts in time */
|
||||||
|
private double getAndroidNougatRecStartTimestamp() {
|
||||||
|
// we can get explicit recording timestamp from API level 24 (Android 7.0 Nougat)
|
||||||
|
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
Log.i(TAG, "getting record timestamp...");
|
||||||
|
AudioTimestamp tss = new AudioTimestamp();
|
||||||
|
if(mRecord.getTimestamp(tss, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS) {
|
||||||
|
long frameTimeNs = (long) (tss.framePosition * 1e9) / mSamplingRate;
|
||||||
|
// recordStartNanoTime should be nanoTime at where the recording has started
|
||||||
|
long recordStartNanoTime = tss.nanoTime - frameTimeNs;
|
||||||
|
Log.i(TAG, "got a record timestamp for framePosition=" + tss.framePosition + " at nanoTime=" + tss.nanoTime + " -> recordStartNanoTime=" + recordStartNanoTime + " and System.nanoTime=" + System.nanoTime());
|
||||||
|
|
||||||
|
mTsAccuracy = AudioStartTimeAccuracy.HIGH;
|
||||||
|
|
||||||
|
return recordStartNanoTime / 1e9;
|
||||||
|
}
|
||||||
|
Log.e(TAG, "could not get record timestamp despite API level >= 24.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback below API 24 -- using the hackish playback triggers above
|
||||||
|
return mNanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onAudioBuffer(ByteBuffer buffer, int nbytes) {
|
||||||
|
if(CHANNEL_NUM != 1 || AUDIO_FORMAT_BYTES != 2)
|
||||||
|
throw new IllegalStateException("onAudioBuffer() was not written for this format");
|
||||||
|
|
||||||
|
if(mDataPCM.bodyLength() == 0) {
|
||||||
|
mNanoTime = getAndroidNougatRecStartTimestamp();
|
||||||
|
mDataPCM.setStartTime(mNanoTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
mDataPCM.addData(buffer, nbytes);
|
||||||
|
|
||||||
|
mNanoTime += nbytes/STRIDE * mNanoTimeStep;
|
||||||
|
|
||||||
|
if((mNanoTime - mPrevTimestamp) > mMinDataEventTime) {
|
||||||
|
final int offset = mPrevOffset;
|
||||||
|
if(mListener != null) {
|
||||||
|
final int length = (mDataPCM.length() - offset);
|
||||||
|
Log.i(TAG, "posting onData(offset=" + offset + ") with rel length=" + length);
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onData(offset, length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mPrevTimestamp = mNanoTime;
|
||||||
|
mPrevOffset = mDataPCM.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void quit() {
|
||||||
|
mStopped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitOnState(final ISensor.State newState) {
|
||||||
|
State oldState;
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
oldState = mState;
|
||||||
|
mState = newState;
|
||||||
|
}
|
||||||
|
if(mListener == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if(!newState.equals(oldState)) {
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onState(newState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal number of sample values per second.
|
||||||
|
* Only valid after the first call to {@link ISensor#start}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public double getFps() {
|
||||||
|
if(mFps != null)
|
||||||
|
return mFps;
|
||||||
|
else
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** start measurement, resetting sensor data */
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
// fail early (we currently assume AudioRecord.read() result is little endian)
|
||||||
|
if(!ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN))
|
||||||
|
throw new IllegalStateException("implement handling of big endian");
|
||||||
|
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
if(mState != State.STOPPED && mState != State.FAILED)
|
||||||
|
throw new IllegalStateException("start() called in invalid state: " + mState.toString());
|
||||||
|
mState = State.STARTING;
|
||||||
|
}
|
||||||
|
|
||||||
|
mDataPCM.clear();
|
||||||
|
mPrevTimestamp = 0;
|
||||||
|
mPrevOffset = 0;
|
||||||
|
|
||||||
|
mRecorder = new AudioRecorder();
|
||||||
|
mRecorder.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stop measurement, keeping sensor data */
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
if(mRecorder == null)
|
||||||
|
return;
|
||||||
|
mRecorder.quit();
|
||||||
|
try {
|
||||||
|
mRecorder.join();
|
||||||
|
//emitOnState(State.STOPPED); // emitted in AudioRecorder thread upon exit
|
||||||
|
} catch(InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
emitOnState(State.STOPPED);
|
||||||
|
}
|
||||||
|
mRecorder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopAsync() {
|
||||||
|
// stop() contains a blocking thread join, may take upto 1/(2*maxDataEventRate) seconds
|
||||||
|
// this is a faster alternative.
|
||||||
|
|
||||||
|
if(mRecorder == null)
|
||||||
|
return;
|
||||||
|
mRecorder.quit();
|
||||||
|
mRecorder = null;
|
||||||
|
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
mState = State.STOPPING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series of each dimension: e.g. {{t1, t2, ...}, {x1, x2, ...}}
|
||||||
|
* Deep copies are returned.
|
||||||
|
*
|
||||||
|
* @return sensor data recorded since {@link ISensor#start} was called
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<double[]> getSamples() { throw new RuntimeException("not implemented"); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns a deep copy of the underlying WAV data.
|
||||||
|
* Call might fail with OutOfMemoryError
|
||||||
|
*/
|
||||||
|
public byte[] getPCM() {
|
||||||
|
/*
|
||||||
|
* since Google Volley requires a byte[] body,
|
||||||
|
* we need to deep-copy and cannot return a ByteBuffer here,
|
||||||
|
* even if we could build the WAV file in-place.
|
||||||
|
*/
|
||||||
|
ByteBuffer buf = mDataPCM.getData();
|
||||||
|
byte[] data = new byte[buf.remaining()];
|
||||||
|
buf.get(data, 0, data.length);
|
||||||
|
updateWavHeader(data, mSamplingRate, CHANNEL_NUM, AUDIO_FORMAT_BYTES * 8);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** update the leading WAV header bytes, so data will be a full WAV file */
|
||||||
|
private void updateWavHeader(byte[] buffer, int sampleRate, int channels, int bitsPerSample) {
|
||||||
|
WavData wavData = new WavData(mDataPCM.bodyLength(), sampleRate, channels, bitsPerSample);
|
||||||
|
byte[] header = wavData.waveHeader();
|
||||||
|
System.arraycopy(header, 0, buffer, 0, header.length); // write WAV header to buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** start time in nanoTime timebase */
|
||||||
|
public long getStartTimeNanos() { return (long) (mDataPCM.getStartTime() * 1e9); }
|
||||||
|
|
||||||
|
/** @return number of samples */
|
||||||
|
@Override
|
||||||
|
public int length() {
|
||||||
|
return mDataPCM.length() / STRIDE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** events are delivered to the main thread, but pixel handling occurs in a separate thread */
|
||||||
|
@Override
|
||||||
|
public void setListener(ISensor.Listener listener) { mListener = listener; }
|
||||||
|
|
||||||
|
/** obtain details after FAILED state */
|
||||||
|
@Override
|
||||||
|
public Exception getError() { return mError; }
|
||||||
|
|
||||||
|
private AudioStartTimeAccuracy mTsAccuracy = null;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AudioStartTimeAccuracy getExpectedStartTimeAccuracy() {
|
||||||
|
return mTsAccuracy;
|
||||||
|
}
|
||||||
|
}
|
||||||
551
app/src/main/java/net/heartshield/sensors/AudioSensorBase.java
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import android.media.AudioFormat;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.AudioRecord;
|
||||||
|
import android.media.AudioTimestamp;
|
||||||
|
import android.media.AudioTrack;
|
||||||
|
import android.media.MediaRecorder;
|
||||||
|
import android.media.MediaSyncEvent;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.os.Process;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import net.heartshield.data.WavData;
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
import net.heartshield.signal.SVec;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.ShortBuffer;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio time series signal capture.
|
||||||
|
* AudioTimestamp needs API level 19.
|
||||||
|
*
|
||||||
|
* Events are delivered to the main thread, but data handling occurs in a separate thread.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-05-16
|
||||||
|
*/
|
||||||
|
public class AudioSensorBase implements IAudioSensorBase {
|
||||||
|
private static final String TAG = "AudioSensorBase";
|
||||||
|
|
||||||
|
private static final int READ_TO_BUF_SIZE_RATIO = 2; // in code, always refer to mReadToBufSizeRatio which is testable
|
||||||
|
private int mReadToBufSizeRatio; // for testing
|
||||||
|
|
||||||
|
private AudioRecorder mRecorder;
|
||||||
|
private State mState;
|
||||||
|
private final Object mStateLock = new Object();
|
||||||
|
|
||||||
|
private double mPrevTimestamp; // in nanoseconds
|
||||||
|
private double mMinDataEventTime; // in seconds // TODO rename to *Sec
|
||||||
|
private int mPrevOffset;
|
||||||
|
|
||||||
|
private Handler mHandler;
|
||||||
|
private Double mFps;
|
||||||
|
private SensorDataPCM mDataPCM;
|
||||||
|
private Listener mListener = null;
|
||||||
|
private Exception mError = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param maxDataEventRate maximum rate of onData() events emitted per second
|
||||||
|
*/
|
||||||
|
public AudioSensorBase(double maxDataEventRate) {
|
||||||
|
|
||||||
|
mMinDataEventTime = 1.0 / maxDataEventRate;
|
||||||
|
mReadToBufSizeRatio = READ_TO_BUF_SIZE_RATIO;
|
||||||
|
|
||||||
|
mHandler = new Handler(Looper.getMainLooper());
|
||||||
|
mDataPCM = new SensorDataPCM();
|
||||||
|
mState = State.STOPPED;
|
||||||
|
|
||||||
|
initSamplingRate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public State getState() { return mState; }
|
||||||
|
|
||||||
|
/** for testing only! must be set before calling start() */
|
||||||
|
@Override
|
||||||
|
public void setReadToBufSizeRatio(int ratio) {
|
||||||
|
mReadToBufSizeRatio = ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int AUDIO_SOURCE = MediaRecorder.AudioSource.MIC;
|
||||||
|
private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO;
|
||||||
|
private static final int CHANNEL_NUM = 1;
|
||||||
|
private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
|
||||||
|
private static final int AUDIO_FORMAT_BYTES = 2;
|
||||||
|
private static final int STRIDE = CHANNEL_NUM * AUDIO_FORMAT_BYTES;
|
||||||
|
private int mSamplingRate;
|
||||||
|
private int mMinBufferSize;
|
||||||
|
private int mBufferSize;
|
||||||
|
private int mReadSize;
|
||||||
|
private long mReadSpacingNanos; // usual time between two read() calls
|
||||||
|
private long mReadSpacingMaxNanos; // maximum time elapsed between two read() calls before we'll start dropping frames
|
||||||
|
|
||||||
|
public class AudioException extends RuntimeException {
|
||||||
|
public AudioException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
public AudioException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AudioLagException extends AudioException {
|
||||||
|
public AudioLagException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initSamplingRate() {
|
||||||
|
// see Android CDD (compatibility definition document) at http://source.android.com/compatibility/6.0/android-6.0-cdd.html
|
||||||
|
// current version for Android 7.1 at https://static.googleusercontent.com/media/source.android.com/en//compatibility/android-cdd.pdf
|
||||||
|
// "Device implementations that declare android.hardware.microphone MUST allow capture of raw audio content with [...] Sampling rates : 8000, 11025, 16000, 44100"
|
||||||
|
// "SHOULD allow capture of raw audio content with [...] Sampling rates : 22050, 48000"
|
||||||
|
final int[] RATES = new int[]{48000, 44100};
|
||||||
|
for(int rate : RATES) {
|
||||||
|
int bufferSize = AudioRecord.getMinBufferSize(rate, CHANNEL_CONFIG, AUDIO_FORMAT);
|
||||||
|
// rate is supported if bufferSize is valid
|
||||||
|
if(bufferSize > 0) {
|
||||||
|
mSamplingRate = rate;
|
||||||
|
mFps = (double) rate;
|
||||||
|
mMinBufferSize = bufferSize;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("could not find a valid audio sampling rate");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initBufferSize() {
|
||||||
|
if(mMinBufferSize == 0)
|
||||||
|
throw new IllegalStateException("call initSamplingRate() first");
|
||||||
|
|
||||||
|
int bufSizePerSec = AUDIO_FORMAT_BYTES * mSamplingRate * CHANNEL_NUM;
|
||||||
|
int idealBufferSize = (int) (bufSizePerSec * mMinDataEventTime * mReadToBufSizeRatio); // buf itself is bigger to accomodate several smaller reads
|
||||||
|
|
||||||
|
// size buffer so one time interval between onData() events will fit.
|
||||||
|
// since we have a background handler reading data, we can probably do with much less.
|
||||||
|
mBufferSize = Math.max(mMinBufferSize, idealBufferSize);
|
||||||
|
|
||||||
|
mReadSize = mBufferSize / mReadToBufSizeRatio;
|
||||||
|
mReadSpacingNanos = (long) (1e9 * mReadSize / bufSizePerSec);
|
||||||
|
mReadSpacingMaxNanos = (long) (1e9 * mBufferSize / bufSizePerSec * 1.2); // note: for laissez-faire audio handling
|
||||||
|
|
||||||
|
Log.i(TAG, "buffer size per sec: " + bufSizePerSec);
|
||||||
|
Log.i(TAG, "min buffer size: " + mMinBufferSize);
|
||||||
|
Log.i(TAG, "chosen buffer size: " + mBufferSize);
|
||||||
|
Log.i(TAG, "chosen read size: " + mReadSize);
|
||||||
|
Log.i(TAG, "chosen read spacing: " + mReadSpacingNanos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int PLAYBACK_TIME_IN_MS = 200;
|
||||||
|
//public static final int START_TIME_MS = PLAYBACK_TIME_IN_MS + 500;
|
||||||
|
|
||||||
|
private class AudioRecorder extends Thread {
|
||||||
|
boolean mStopped = false;
|
||||||
|
|
||||||
|
// see Android CDD: "The device MUST allow playback of raw audio content with the following characteristics: [...] Sampling rates : 8000"
|
||||||
|
final int PLAYBACK_SAMPLE_RATE = 8000;
|
||||||
|
// TODO: rename 2x below to Secs
|
||||||
|
private double mNanoTime; // recording time as far as samples have progressed
|
||||||
|
private double mNanoTimeStep; // nanoseconds between each sample
|
||||||
|
|
||||||
|
private AudioRecord mRecord;
|
||||||
|
|
||||||
|
private AudioTrack createSilentTrack() {
|
||||||
|
final int frameCount = PLAYBACK_TIME_IN_MS * PLAYBACK_SAMPLE_RATE / 1000;
|
||||||
|
final int frameSize = 1;
|
||||||
|
final int sampleCount = frameCount * 1; // * format.getChannelCount(); // getChannelCount() would need API level 23
|
||||||
|
final int bufSize = frameSize * sampleCount;
|
||||||
|
|
||||||
|
AudioTrack track = new AudioTrack(AudioManager.STREAM_NOTIFICATION, PLAYBACK_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_8BIT, bufSize, AudioTrack.MODE_STATIC);
|
||||||
|
// AudioTrack(int streamType, int sampleRateInHz, int channelConfig, int audioFormat, int bufferSizeInBytes, int mode)
|
||||||
|
/*
|
||||||
|
new AudioTrack.Builder()
|
||||||
|
.setAudioFormat(format)
|
||||||
|
.setBufferSizeInBytes(frameCount * frameSize)
|
||||||
|
.setTransferMode(AudioTrack.MODE_STATIC)
|
||||||
|
.build();
|
||||||
|
*/
|
||||||
|
// create frame array and write it
|
||||||
|
|
||||||
|
byte[] vab = new byte[bufSize];
|
||||||
|
//Arrays.fill(vab, -128);
|
||||||
|
for(int i = 0; i < vab.length; i++)
|
||||||
|
vab[i] = -128;
|
||||||
|
track.write(vab, 0 /* offsetInBytes */, vab.length); // copies audio data for later playback (static buffer mode)
|
||||||
|
|
||||||
|
track.setNotificationMarkerPosition(frameCount-1);
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
runRecording();
|
||||||
|
} catch(Throwable t) {
|
||||||
|
mError = new AudioException(t);
|
||||||
|
Log.e(TAG, "error during recording", mError);
|
||||||
|
emitOnState(AudioSensorBase.State.FAILED);
|
||||||
|
} finally {
|
||||||
|
if(mRecord != null)
|
||||||
|
mRecord.release();
|
||||||
|
mRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runRecording() {
|
||||||
|
Log.d(TAG, "entering AudioRecorder thread");
|
||||||
|
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO);
|
||||||
|
|
||||||
|
initBufferSize();
|
||||||
|
|
||||||
|
/* Play back a short, silent AudioTrack to use MediaSyncEvent to sync to it */
|
||||||
|
|
||||||
|
AudioTrack track = createSilentTrack();
|
||||||
|
final int trackSessionId = track.getAudioSessionId();
|
||||||
|
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocateDirect(mBufferSize);
|
||||||
|
mRecord = new AudioRecord(AUDIO_SOURCE, mSamplingRate, CHANNEL_CONFIG, AUDIO_FORMAT, mBufferSize);
|
||||||
|
|
||||||
|
// 3. create a MediaSyncEvent
|
||||||
|
final int eventType = MediaSyncEvent.SYNC_EVENT_PRESENTATION_COMPLETE;
|
||||||
|
MediaSyncEvent event = MediaSyncEvent.createEvent(eventType)
|
||||||
|
.setAudioSessionId(trackSessionId);
|
||||||
|
|
||||||
|
// the marker for this is set with setNotificationMarkerPosition() in createSilentTrack()
|
||||||
|
track.setPlaybackPositionUpdateListener(new AudioTrack.OnPlaybackPositionUpdateListener() {
|
||||||
|
@Override
|
||||||
|
public void onMarkerReached(AudioTrack track) {
|
||||||
|
Log.i(TAG, "onMarkerReached(), switching to RUNNING state");
|
||||||
|
emitOnState(AudioSensorBase.State.RUNNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPeriodicNotification(AudioTrack track) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. now set the AudioTrack playing and start the recording synchronized
|
||||||
|
track.play();
|
||||||
|
|
||||||
|
// this returns immediately, but does not start recording yet
|
||||||
|
Log.d(TAG, "startRecording(event), should return immediately");
|
||||||
|
mRecord.startRecording(event);
|
||||||
|
|
||||||
|
long beforeSleepNanoTime = System.nanoTime();
|
||||||
|
Log.d(TAG, "startRecording(event) returned, beforeSleepNanoTime=" + beforeSleepNanoTime);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(PLAYBACK_TIME_IN_MS / 2); // ensure that audio is playing so we can poll for a timestamp.
|
||||||
|
} catch(InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// best guess for when the recording actually starts in time
|
||||||
|
AudioTimestamp ts = new AudioTimestamp(); // AudioTimestamp needs API level 19
|
||||||
|
boolean startTimeGood = track.getTimestamp(ts);
|
||||||
|
long recordStartNanoTime;
|
||||||
|
if(startTimeGood) {
|
||||||
|
// frameTimeNs should be roughly PLAYBACK_TIME_IN_MS / 2
|
||||||
|
long frameTimeNs = (long) (ts.framePosition * 1e9) / PLAYBACK_SAMPLE_RATE;
|
||||||
|
// recordStartNanoTime should be nanoTime at where the playback will end
|
||||||
|
recordStartNanoTime = ts.nanoTime - frameTimeNs + (long) (PLAYBACK_TIME_IN_MS * 1e6);
|
||||||
|
Log.i(TAG, "got a timestamp for framePosition=" + ts.framePosition + " at nanoTime=" + ts.nanoTime);
|
||||||
|
} else {
|
||||||
|
recordStartNanoTime = beforeSleepNanoTime + (long) (PLAYBACK_TIME_IN_MS * 1e6);
|
||||||
|
Log.e(TAG, "AudioTrack.getTimestamp() not supported! Audio timestamps will be inaccurate."); // (~ second accuracy?!)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "recordStartNanoTime=" + recordStartNanoTime + " and System.nanoTime=" + System.nanoTime());
|
||||||
|
|
||||||
|
mNanoTime = recordStartNanoTime / 1e9;
|
||||||
|
mNanoTimeStep = 1e9 / mSamplingRate;
|
||||||
|
|
||||||
|
short[] sbuf = new short[mReadSize / AUDIO_FORMAT_BYTES];
|
||||||
|
|
||||||
|
mPrevTimeNanos = 0;
|
||||||
|
while(!mStopped) {
|
||||||
|
/*
|
||||||
|
* AudioRecord.read() blocks until the specified buffer size has been read in full.
|
||||||
|
*
|
||||||
|
* We can sanity-check that it has not been dropping frames through
|
||||||
|
*
|
||||||
|
* 1) checking the time at which buffers were returned.
|
||||||
|
* Assuming huge CPU lags are the cause of dropping, delivery will not be on time.
|
||||||
|
*
|
||||||
|
* 2) From Android API level 24, using {@link AudioRecord#getTimestamp}
|
||||||
|
*
|
||||||
|
* Alternative approaches could be:
|
||||||
|
*
|
||||||
|
* 3) undocumented, unexposed Android JNI API AudioRecord::getInputFramesLost() -- the type has changed (unsigned int -> uint32_t)
|
||||||
|
*
|
||||||
|
* 4) registering a Listener with AudioRecord.setRecordPositionUpdateListener()
|
||||||
|
* and AudioRecord.setPositionNotificationPeriod()
|
||||||
|
* However, docs note that "It is possible for notifications to be lost if the period is too small."
|
||||||
|
*/
|
||||||
|
|
||||||
|
// note: pulled PCM "will be native endian" -- but we'll write it to WAV, which is little endian
|
||||||
|
// see https://developer.android.com/reference/android/media/AudioRecord.html#read(java.nio.ByteBuffer, int)
|
||||||
|
|
||||||
|
if(!ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN))
|
||||||
|
throw new IllegalStateException("implement handling of big endian");
|
||||||
|
|
||||||
|
/*
|
||||||
|
int nbytes = mRecord.read(buffer, mReadSize);
|
||||||
|
// TODO: read shorts instead, using read(@NonNull short[] audioData, int offsetInShorts, int sizeInShorts)
|
||||||
|
*/
|
||||||
|
|
||||||
|
int nsamples = mRecord.read(sbuf, 0, mReadSize / AUDIO_FORMAT_BYTES);
|
||||||
|
int nbytes = 2*nsamples;
|
||||||
|
final double[] buf = DVec.mul(SVec.toDouble(sbuf), 1.0 / 32768.0);
|
||||||
|
|
||||||
|
mDataPCM.addData(short2pcmBuffer(sbuf), nbytes);
|
||||||
|
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onData(buf);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
long time = System.nanoTime();
|
||||||
|
if(mPrevTimeNanos != 0) {
|
||||||
|
long dt = time - mPrevTimeNanos;
|
||||||
|
if(dt > mReadSpacingMaxNanos)
|
||||||
|
throw new AudioLagException("audio lag: time between reads was " + dt + " nanos, which is larger than our buffer of " + mReadSpacingNanos + " nanos");
|
||||||
|
Log.d(TAG, "onAudioBuffer received nbytes=" + nbytes + " at t=" + time + ", dt=" + dt);
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "onAudioBuffer received nbytes=" + nbytes + " at t=" + time + " first-time");
|
||||||
|
}
|
||||||
|
mPrevTimeNanos = time;
|
||||||
|
|
||||||
|
if(nbytes < 0)
|
||||||
|
throw new RuntimeException("AudioRecord.read() returned error=" + nbytes);
|
||||||
|
|
||||||
|
//onAudioBuffer(buffer, nbytes);
|
||||||
|
}
|
||||||
|
emitOnState(AudioSensorBase.State.STOPPED);
|
||||||
|
Log.d(TAG, "exiting AudioRecorder thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long mPrevTimeNanos = 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
private void onAudioBuffer(ByteBuffer buffer, int nbytes) {
|
||||||
|
if(CHANNEL_NUM != 1 || AUDIO_FORMAT_BYTES != 2)
|
||||||
|
throw new IllegalStateException("onAudioBuffer() was not written for this format");
|
||||||
|
|
||||||
|
if(mDataPCM.length() == 0)
|
||||||
|
mDataPCM.setStartTime(mNanoTime);
|
||||||
|
|
||||||
|
mDataPCM.addData(buffer, nbytes);
|
||||||
|
|
||||||
|
mNanoTime += nbytes/STRIDE * mNanoTimeStep;
|
||||||
|
|
||||||
|
if((mNanoTime - mPrevTimestamp) > mMinDataEventTime) {
|
||||||
|
final int offset = mPrevOffset;
|
||||||
|
if(mListener != null) {
|
||||||
|
final int length = (mDataPCM.length() - offset);
|
||||||
|
Log.i(TAG, "posting onData(offset=" + offset + ") with rel length=" + length);
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onData(offset, length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mPrevTimestamp = mNanoTime;
|
||||||
|
mPrevOffset = mDataPCM.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
private void onAudioBuffer(ByteBuffer buffer, int nbytes) {
|
||||||
|
if(CHANNEL_NUM != 1 || AUDIO_FORMAT_BYTES != 2)
|
||||||
|
throw new IllegalStateException("onAudioBuffer() was not written for this format");
|
||||||
|
|
||||||
|
int stride = CHANNEL_NUM * AUDIO_FORMAT_BYTES;
|
||||||
|
//double[] buf = new double[2];
|
||||||
|
final double[] buf = new double[nbytes/stride];
|
||||||
|
final boolean le = ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
|
||||||
|
int j = 0;
|
||||||
|
for(int i = 0; i < nbytes; i += stride) {
|
||||||
|
// PCM_16BIT is "native endian": https://developer.android.com/reference/android/media/AudioFormat.html#encoding
|
||||||
|
short lsb = buffer.get(i);
|
||||||
|
short msb = buffer.get(i+1);
|
||||||
|
short sample = (short) (le ? ((msb << 8) | lsb) : ((lsb << 8) | msb));
|
||||||
|
buf[j++] = sample / 32768.0;
|
||||||
|
mNanoTime += mNanoTimeStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
if(mListener != null) {
|
||||||
|
//final int length = (mData.length() - offset);
|
||||||
|
//Log.i(TAG, "posting onData(offset=" + offset + ") with rel length=" + length);
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onData(buf);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mPrevTimestamp = mNanoTime;
|
||||||
|
//mPrevOffset = mData.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
void quit() {
|
||||||
|
mStopped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] short2pcmBytes(short[] arr) {
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(arr.length * 2);
|
||||||
|
bb.order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
bb.asShortBuffer().put(arr);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static ByteBuffer short2pcmBuffer(short[] arr) {
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(arr.length * 2);
|
||||||
|
bb.order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
bb.asShortBuffer().put(arr);
|
||||||
|
return bb;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void emitOnState(final State newState) {
|
||||||
|
State oldState;
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
oldState = mState;
|
||||||
|
mState = newState;
|
||||||
|
}
|
||||||
|
if(mListener == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if(!newState.equals(oldState)) {
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onState(newState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal number of sample values per second.
|
||||||
|
* Only valid after the first call to {@link ISensor#start}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public double getFps() {
|
||||||
|
if(mFps != null)
|
||||||
|
return mFps;
|
||||||
|
else
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** start measurement, resetting sensor data */
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
// fail early (we currently assume AudioRecord.read() result is little endian)
|
||||||
|
if(!ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN))
|
||||||
|
throw new IllegalStateException("implement handling of big endian");
|
||||||
|
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
if(mState != State.STOPPED && mState != State.FAILED)
|
||||||
|
throw new IllegalStateException("start() called in invalid state: " + mState.toString());
|
||||||
|
mState = State.STARTING;
|
||||||
|
}
|
||||||
|
|
||||||
|
mDataPCM.clear();
|
||||||
|
mPrevTimestamp = 0;
|
||||||
|
mPrevOffset = 0;
|
||||||
|
|
||||||
|
mRecorder = new AudioRecorder();
|
||||||
|
mRecorder.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stop measurement, keeping sensor data */
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
if(mRecorder == null)
|
||||||
|
return;
|
||||||
|
mRecorder.quit();
|
||||||
|
try {
|
||||||
|
mRecorder.join();
|
||||||
|
//emitOnState(State.STOPPED); // emitted in AudioRecorder thread upon exit
|
||||||
|
} catch(InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
emitOnState(State.STOPPED);
|
||||||
|
}
|
||||||
|
mRecorder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopAsync() {
|
||||||
|
// stop() contains a blocking thread join, may take upto 1/(2*maxDataEventRate) seconds
|
||||||
|
// this is a faster alternative.
|
||||||
|
|
||||||
|
if(mRecorder == null)
|
||||||
|
return;
|
||||||
|
mRecorder.quit();
|
||||||
|
mRecorder = null;
|
||||||
|
|
||||||
|
synchronized(mStateLock) {
|
||||||
|
mState = State.STOPPING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<double[]> getSamples() { throw new RuntimeException("not implemented"); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns a deep copy of the underlying WAV data.
|
||||||
|
* Call might fail with OutOfMemoryError
|
||||||
|
*/
|
||||||
|
public byte[] getPCM() {
|
||||||
|
/*
|
||||||
|
* since Google Volley requires a byte[] body,
|
||||||
|
* we need to deep-copy and cannot return a ByteBuffer here,
|
||||||
|
* even if we could build the WAV file in-place.
|
||||||
|
*/
|
||||||
|
ByteBuffer buf = mDataPCM.getData();
|
||||||
|
byte[] data = new byte[buf.remaining()];
|
||||||
|
buf.get(data, 0, data.length);
|
||||||
|
updateWavHeader(data, mSamplingRate, CHANNEL_NUM, AUDIO_FORMAT_BYTES * 8);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** update the leading WAV header bytes, so data will be a full WAV file */
|
||||||
|
private void updateWavHeader(byte[] buffer, int sampleRate, int channels, int bitsPerSample) {
|
||||||
|
WavData wavData = new WavData(mDataPCM.bodyLength(), sampleRate, channels, bitsPerSample);
|
||||||
|
byte[] header = wavData.waveHeader();
|
||||||
|
System.arraycopy(header, 0, buffer, 0, header.length); // write WAV header to buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** start time in nanoTime timebase */
|
||||||
|
public long getStartTimeNanos() { return (long) (mDataPCM.getStartTime() * 1e9); }
|
||||||
|
|
||||||
|
/** @return number of samples */
|
||||||
|
public int length() {
|
||||||
|
return mDataPCM.length() / STRIDE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** events are delivered to the main thread, but pixel handling occurs in a separate thread */
|
||||||
|
@Override
|
||||||
|
public void setListener(Listener listener) { mListener = listener; }
|
||||||
|
|
||||||
|
/** obtain details after FAILED state */
|
||||||
|
public Exception getError() { return mError; }
|
||||||
|
}
|
||||||
1454
app/src/main/java/net/heartshield/sensors/Camera2Detector.java
Normal file
167
app/src/main/java/net/heartshield/sensors/CameraUtil.java
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-09
|
||||||
|
*/
|
||||||
|
public class CameraUtil {
|
||||||
|
static public double fractionRed(int[] rgb, int width, int height) {
|
||||||
|
long nred = 0;
|
||||||
|
for(int j = 0, yp = 0; j < height; j++) {
|
||||||
|
for(int i = 0; i < width; i++, yp++) {
|
||||||
|
final int r = (rgb[yp] & 0xff0000) >> 16;
|
||||||
|
final int g = (rgb[yp] & 0xff00) >> 8;
|
||||||
|
final int b = (rgb[yp] & 0xff);
|
||||||
|
if(r > g && r > b)
|
||||||
|
nred++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ((double) nred) / (width * height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute column-wise mean.
|
||||||
|
*
|
||||||
|
* @param rgb input image pixel matrix
|
||||||
|
* @param width input width
|
||||||
|
* @param height input height
|
||||||
|
* @return output row (length = width)
|
||||||
|
*/
|
||||||
|
static public double[][] meanRow(int[] rgb, int width, int height) {
|
||||||
|
double[][] rgbMean = new double[3][width];
|
||||||
|
|
||||||
|
for(int j = 0, yp = 0; j < height; j++) {
|
||||||
|
for(int i = 0; i < width; i++, yp++) {
|
||||||
|
rgbMean[0][i] += (rgb[yp] & 0xff0000) >> 16;
|
||||||
|
rgbMean[1][i] += (rgb[yp] & 0xff00) >> 8;
|
||||||
|
rgbMean[2][i] += (rgb[yp] & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int i = 0; i < width; i++) {
|
||||||
|
for(int k = 0; k < 3; k++)
|
||||||
|
rgbMean[k][i] /= height;
|
||||||
|
}
|
||||||
|
return rgbMean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute row-wise mean.
|
||||||
|
*
|
||||||
|
* @param rgb input image pixel matrix
|
||||||
|
* @param width input width
|
||||||
|
* @param height input height
|
||||||
|
* @return output column (length = height)
|
||||||
|
*/
|
||||||
|
static public double[][] meanCol(int[] rgb, int width, int height) {
|
||||||
|
double[][] rgbMean = new double[3][height];
|
||||||
|
|
||||||
|
for(int j = 0, yp = 0; j < height; j++) {
|
||||||
|
for(int i = 0; i < width; i++, yp++) {
|
||||||
|
rgbMean[0][j] += (rgb[yp] & 0xff0000) >> 16;
|
||||||
|
rgbMean[1][j] += (rgb[yp] & 0xff00) >> 8;
|
||||||
|
rgbMean[2][j] += (rgb[yp] & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int i = 0; i < height; i++) {
|
||||||
|
for(int k = 0; k < 3; k++)
|
||||||
|
rgbMean[k][i] /= width;
|
||||||
|
}
|
||||||
|
return rgbMean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute column-wise standard deviation.
|
||||||
|
*
|
||||||
|
* @param rgb input image pixel matrix
|
||||||
|
* @param width input width
|
||||||
|
* @param height input height
|
||||||
|
* @return output row (length = width)
|
||||||
|
*/
|
||||||
|
static public double[][] stdRow(int[] rgb, double[][] rgbMean, int width, int height) {
|
||||||
|
double[][] rgbStd = new double[3][width];
|
||||||
|
|
||||||
|
for(int j = 0, yp = 0; j < height; j++) {
|
||||||
|
for(int i = 0; i < width; i++, yp++) {
|
||||||
|
double r = ((rgb[yp] & 0xff0000) >> 16) - rgbMean[0][i];
|
||||||
|
double g = ((rgb[yp] & 0xff00) >> 8) - rgbMean[1][i];
|
||||||
|
double b = (rgb[yp] & 0xff) - rgbMean[2][i];
|
||||||
|
rgbStd[0][i] += r * r;
|
||||||
|
rgbStd[1][i] += g * g;
|
||||||
|
rgbStd[2][i] += b * b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int i = 0; i < width; i++) {
|
||||||
|
for(int k = 0; k < 3; k++)
|
||||||
|
rgbStd[k][i] = Math.sqrt(rgbStd[k][i] / height);
|
||||||
|
}
|
||||||
|
return rgbStd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute row-wise standard deviation.
|
||||||
|
*
|
||||||
|
* @param rgb input image pixel matrix
|
||||||
|
* @param width input width
|
||||||
|
* @param height input height
|
||||||
|
* @return output row (length = height)
|
||||||
|
*/
|
||||||
|
static public double[][] stdCol(int[] rgb, double[][] rgbMean, int width, int height) {
|
||||||
|
double[][] rgbStd = new double[3][height];
|
||||||
|
|
||||||
|
for(int j = 0, yp = 0; j < height; j++) {
|
||||||
|
for(int i = 0; i < width; i++, yp++) {
|
||||||
|
double r = ((rgb[yp] & 0xff0000) >> 16) - rgbMean[0][j];
|
||||||
|
double g = ((rgb[yp] & 0xff00) >> 8) - rgbMean[1][j];
|
||||||
|
double b = (rgb[yp] & 0xff) - rgbMean[2][j];
|
||||||
|
rgbStd[0][j] += r * r;
|
||||||
|
rgbStd[1][j] += g * g;
|
||||||
|
rgbStd[2][j] += b * b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int i = 0; i < height; i++) {
|
||||||
|
for(int k = 0; k < 3; k++)
|
||||||
|
rgbStd[k][i] = Math.sqrt(rgbStd[k][i] / width);
|
||||||
|
}
|
||||||
|
return rgbStd;
|
||||||
|
}
|
||||||
|
|
||||||
|
static public void decodeYUV420SP(int[] rgb, double[] mean_rgb, byte[] yuv420sp, int width, int height) {
|
||||||
|
final int frameSize = width * height;
|
||||||
|
long rr = 0, gg = 0, bb = 0;
|
||||||
|
|
||||||
|
for (int j = 0, yp = 0; j < height; j++) {
|
||||||
|
int uvp = frameSize + (j >> 1) * width, u = 0, v = 0;
|
||||||
|
for (int i = 0; i < width; i++, yp++) {
|
||||||
|
int y = (0xff & ((int) yuv420sp[yp])) - 16;
|
||||||
|
if (y < 0) y = 0;
|
||||||
|
if ((i & 1) == 0) {
|
||||||
|
u = (0xff & yuv420sp[uvp++]) - 128;
|
||||||
|
v = (0xff & yuv420sp[uvp++]) - 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
int y1192 = 1192 * y;
|
||||||
|
int r = (y1192 + 1634 * v);
|
||||||
|
int g = (y1192 - 833 * v - 400 * u);
|
||||||
|
int b = (y1192 + 2066 * u);
|
||||||
|
|
||||||
|
if (r < 0) r = 0; else if (r > 262143) r = 262143;
|
||||||
|
if (g < 0) g = 0; else if (g > 262143) g = 262143;
|
||||||
|
if (b < 0) b = 0; else if (b > 262143) b = 262143;
|
||||||
|
|
||||||
|
rgb[yp] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) & 0xff00) | ((b >> 10) & 0xff);
|
||||||
|
|
||||||
|
rr += (r >> 10) & 0xff;
|
||||||
|
gg += (g >> 10) & 0xff;
|
||||||
|
bb += (b >> 10) & 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mean_rgb[0] = ((double) rr) / (width * height);
|
||||||
|
mean_rgb[1] = ((double) gg) / (width * height);
|
||||||
|
mean_rgb[2] = ((double) bb) / (width * height);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import net.heartshield.data.Measurement;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-12
|
||||||
|
*/
|
||||||
|
public class DummyAudioSensor implements IAudioSensor {
|
||||||
|
public DummyAudioSensor() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getPCM() {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getStartTimeNanos() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getFps() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
// TODO: this should start a thread and behave just the same as AudioSensor
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<double[]> getSamples() {
|
||||||
|
List<double[]> l = new ArrayList<>();
|
||||||
|
l.add(new double[0]);
|
||||||
|
l.add(new double[0]);
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int length() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setListener(Listener listener) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Exception getError() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AudioStartTimeAccuracy getExpectedStartTimeAccuracy() {
|
||||||
|
return AudioStartTimeAccuracy.LOW;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/src/main/java/net/heartshield/sensors/IAudioSensor.java
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-12
|
||||||
|
*/
|
||||||
|
public interface IAudioSensor extends ISensor {
|
||||||
|
/**
|
||||||
|
* Time series of sensor data recorded so far.
|
||||||
|
* Deep copies are returned.
|
||||||
|
*
|
||||||
|
* @return sensor data recorded since {@link ISensor#start} was called
|
||||||
|
*/
|
||||||
|
byte[] getPCM();
|
||||||
|
|
||||||
|
/** start time in nanoTime timebase */
|
||||||
|
long getStartTimeNanos();
|
||||||
|
|
||||||
|
/** type of start time acquisition */
|
||||||
|
AudioStartTimeAccuracy getExpectedStartTimeAccuracy();
|
||||||
|
|
||||||
|
enum AudioStartTimeAccuracy {
|
||||||
|
/** about 200 ms accurate - nanoTime() timestamp taken before calling APIs (to playback silence -> trigger recording) */
|
||||||
|
@SerializedName("low")
|
||||||
|
LOW,
|
||||||
|
|
||||||
|
/** about 50 ms accurate - AudioTimestamp from AudioTrack (playback silence, get a timestamp, and trigger recording) */
|
||||||
|
@SerializedName("medium")
|
||||||
|
MEDIUM,
|
||||||
|
|
||||||
|
/** hardware specific - AudioTimestamp from AudioRecord above API 24 (Android 7.0 N) */
|
||||||
|
@SerializedName("high")
|
||||||
|
HIGH
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-05-17
|
||||||
|
*/
|
||||||
|
public interface IAudioSensorBase {
|
||||||
|
double getFps();
|
||||||
|
|
||||||
|
void start();
|
||||||
|
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
byte[] getPCM();
|
||||||
|
|
||||||
|
void setReadToBufSizeRatio(int ratio);
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
STOPPED,
|
||||||
|
STARTING, // internal transition state, not exposed via onState()
|
||||||
|
RUNNING,
|
||||||
|
STOPPING, // internal transition state, not exposed via onState()
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
/**
|
||||||
|
* one or more samples were added
|
||||||
|
*/
|
||||||
|
//void onData(int offset, int length);
|
||||||
|
void onData(double[] buf);
|
||||||
|
|
||||||
|
/** state change occurred */
|
||||||
|
void onState(State newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setListener(Listener listener);
|
||||||
|
}
|
||||||
65
app/src/main/java/net/heartshield/sensors/ISensor.java
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic interface for time series signal capture from a hardware sensor.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-03-21
|
||||||
|
*/
|
||||||
|
public interface ISensor {
|
||||||
|
/**
|
||||||
|
* Nominal number of sample values per second.
|
||||||
|
* Only valid after the first state transition to RUNNING. See {@link ISensor#start}
|
||||||
|
*/
|
||||||
|
double getFps();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start measurement, resetting sensor data.
|
||||||
|
* This call is asynchronous/lightweight and may return before the sensor has started capture.
|
||||||
|
*/
|
||||||
|
void start();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop measurement, keeping sensor data.
|
||||||
|
* This call is blocking and only returns after the sensor is stopped.
|
||||||
|
*/
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series of each dimension: e.g. {{t1, t2, ...}, {x1, x2, ...}}
|
||||||
|
* Deep copies are returned. Time is seconds in {@link System#nanoTime} timebase.
|
||||||
|
*
|
||||||
|
* @return sensor samples recorded since {@link ISensor#start} was called
|
||||||
|
*/
|
||||||
|
List<double[]> getSamples();
|
||||||
|
|
||||||
|
/** @return number of samples */
|
||||||
|
int length();
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
STOPPED,
|
||||||
|
STARTING, // internal transition state, not exposed via onState()
|
||||||
|
RUNNING,
|
||||||
|
STOPPING, // internal transition state, not exposed via onState()
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
/**
|
||||||
|
* one or more samples were added
|
||||||
|
* @param offset start index offset
|
||||||
|
*/
|
||||||
|
void onData(int offset, int length);
|
||||||
|
|
||||||
|
/** state change occurred */
|
||||||
|
void onState(State newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** events are delivered to the main thread, but pixel handling occurs in a separate thread */
|
||||||
|
void setListener(Listener listener);
|
||||||
|
|
||||||
|
/** obtain details after FAILED state */
|
||||||
|
Exception getError();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import android.view.TextureView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes MeasurementController testable by plugging in a different sensor interface provider.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-12
|
||||||
|
*/
|
||||||
|
public interface ISensorFactory {
|
||||||
|
ISensor getAccelerationSensor();
|
||||||
|
IntensityDetector getIntensityDetector(TextureView cameraPreview);
|
||||||
|
IAudioSensor getAudioSensor();
|
||||||
|
}
|
||||||
131
app/src/main/java/net/heartshield/sensors/IntensityDetector.java
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import net.heartshield.util.JsonUtil;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-02-15
|
||||||
|
*
|
||||||
|
* IntensityDetector wraps the Android camera as a light intensity detector.
|
||||||
|
* Output: timestamped average pixel intensity values: {{t_0, r_0, g_0, b_0}, {t_1, r_1, g_1, b_1}, ...}
|
||||||
|
* Timestamps should come from the OS, i.e. SurfaceTexture.getTimestamp() or
|
||||||
|
* It should run in a separate thread from the UI if possible, to maximize FPS.
|
||||||
|
*
|
||||||
|
* States:
|
||||||
|
* - STOPPED ... flash and camera off
|
||||||
|
* - WAITING ... flash on, waiting for auto-exposure to be in the correct range, for baseline drift to be stable
|
||||||
|
* - LOCKED ... locked A3
|
||||||
|
*
|
||||||
|
* You may need another state for camera initialized or not.
|
||||||
|
*
|
||||||
|
* Initially, start() initializes the camera (STOPPED -> WAITING), switches the flash on and then starts providing
|
||||||
|
* frames (r,g,b returned are averages of all pixels of the respective color channel).
|
||||||
|
* When the whitebalance has settled, lock() will lock A3 (AE,AWB,AF) and bring camera from WAITING -> LOCKED.
|
||||||
|
*
|
||||||
|
* Android legacy Camera API
|
||||||
|
* - SurfaceTexture / OnFrameAvailableListener.onFrameAvailable()
|
||||||
|
* Android Camera2 API
|
||||||
|
* - ImageReader / OnImageAvailableListener.onImageAvailable()
|
||||||
|
* - use acquireNextImage() to avoid skipping frames
|
||||||
|
*
|
||||||
|
* The class should either fall back if Camera2 API is not available, or you should provide both implementations with an identical interface.
|
||||||
|
*
|
||||||
|
* It should also handle UI Activity events like onPause()/onResume() and correctly release and re-initialize the camera.
|
||||||
|
* Likely this needs some extensions to the interface.
|
||||||
|
*
|
||||||
|
* Please provide a demo app where integration shown, including Activity events.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public interface IntensityDetector {
|
||||||
|
// may be called in either STOPPED or FAILED, other calls will throw
|
||||||
|
void start() throws IllegalStateException;
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
// may be called in WAITING only, other calls will throw
|
||||||
|
void lock() throws IllegalStateException;
|
||||||
|
|
||||||
|
// may be called in either WAITING or LOCKED, other calls will throw
|
||||||
|
void unlock() throws IllegalStateException;
|
||||||
|
|
||||||
|
State getState();
|
||||||
|
|
||||||
|
// returns the number of frames dropped since start() or lock(), whichever was most recent
|
||||||
|
int getDroppedCount();
|
||||||
|
|
||||||
|
/** may only be available after start() has been called */
|
||||||
|
CameraFeatures getFeatures();
|
||||||
|
|
||||||
|
/** only available after stop() has been called */
|
||||||
|
JSONObject getCameraMeta();
|
||||||
|
|
||||||
|
// optimistic (maximum) FPS. Note that some frames might be dropped, see getDroppedCount()
|
||||||
|
int getFps();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series of each dimension: e.g. {{t1, t2, ...}, {x1, x2, ...}}
|
||||||
|
* Deep copies are returned.
|
||||||
|
*
|
||||||
|
* @return sensor data recorded since {@link ISensor#start} was called
|
||||||
|
*/
|
||||||
|
List<double[]> getSamples();
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
/** flash and camera off */
|
||||||
|
STOPPED,
|
||||||
|
/** flash on, waiting for auto-exposure to be in the correct range, for baseline drift to be stable */
|
||||||
|
WAITING,
|
||||||
|
/** locked A3 */
|
||||||
|
LOCKED,
|
||||||
|
/** if a Camera error happens */
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
class CameraFeatures {
|
||||||
|
public boolean flash;
|
||||||
|
public boolean aeLockAvailable;
|
||||||
|
public boolean awbLockAvailable;
|
||||||
|
public boolean afModeOffAvailable;
|
||||||
|
|
||||||
|
public List<String> afModes;
|
||||||
|
public List<String> aeModes;
|
||||||
|
public List<String> controlModes;
|
||||||
|
|
||||||
|
public String timestampSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageSummary {
|
||||||
|
public double[][] meanRow;
|
||||||
|
public double[][] meanCol;
|
||||||
|
public double[][] stdRow;
|
||||||
|
public double[][] stdCol;
|
||||||
|
public double fractionRed;
|
||||||
|
|
||||||
|
public String serialize() {
|
||||||
|
return JsonUtil.getGson(JsonUtil.Target.REMOTE).toJson(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntensityListener {
|
||||||
|
void onState(State newState);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* note: summary is only available in State.WAITING
|
||||||
|
*
|
||||||
|
* @param timestamp sourced from {@link android.media.Image#getTimestamp()},
|
||||||
|
* and only in practice it has only coincidental relation
|
||||||
|
* only on SOME PHONES to nanoTime timebase of {@link System#nanoTime()}
|
||||||
|
*
|
||||||
|
* @param cpuTimeNanos rough timestamp that is in nanoTime timebase. Acquired in the image callback on CPU.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
void onFrame(long timestamp, long cpuTimeNanos, double[] rgb, ImageSummary summary);
|
||||||
|
}
|
||||||
|
void setIntensityListener(IntensityListener listener);
|
||||||
|
|
||||||
|
void setDebugMode(boolean debugMode);
|
||||||
|
}
|
||||||
128
app/src/main/java/net/heartshield/sensors/IntensitySensor.java
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.view.TextureView;
|
||||||
|
|
||||||
|
import net.heartshield.signal.DVec;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Camera brightness time series signal capture.
|
||||||
|
*
|
||||||
|
* Events are delivered to the main thread, but pixel handling occurs in a separate thread.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-03-21
|
||||||
|
*/
|
||||||
|
public class IntensitySensor implements IntensityDetector.IntensityListener, ISensor {
|
||||||
|
private IntensityDetector mDetector;
|
||||||
|
private Handler mHandler;
|
||||||
|
private Double mFps;
|
||||||
|
private SensorData mData;
|
||||||
|
private ISensor.Listener mListener = null;
|
||||||
|
|
||||||
|
private static final int NDIM = 3; // r,g,b channels
|
||||||
|
|
||||||
|
public IntensitySensor(Activity activity, TextureView cameraPreview, int width, int height) {
|
||||||
|
mDetector = new Camera2Detector(activity, cameraPreview, width, height, /* stickyUiThread */ false);
|
||||||
|
mDetector.setIntensityListener(this);
|
||||||
|
mHandler = new Handler(Looper.getMainLooper());
|
||||||
|
mData = new SensorData(NDIM);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitOnState(final ISensor.State newState) {
|
||||||
|
if(mListener == null)
|
||||||
|
return;
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onState(newState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onState(IntensityDetector.State newState) {
|
||||||
|
switch(newState) {
|
||||||
|
case STOPPED:
|
||||||
|
emitOnState(State.STOPPED);
|
||||||
|
break;
|
||||||
|
case WAITING:
|
||||||
|
mFps = (double) mDetector.getFps();
|
||||||
|
emitOnState(State.RUNNING);
|
||||||
|
break;
|
||||||
|
case LOCKED:
|
||||||
|
break;
|
||||||
|
case FAILED:
|
||||||
|
emitOnState(State.FAILED);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalStateException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrame(long timestamp, long cpuTimeNanos, double[] rgb, IntensityDetector.ImageSummary summary) {
|
||||||
|
|
||||||
|
final int offset = mData.length();
|
||||||
|
mData.addSample(DVec.stack(new double[]{ ((double) timestamp) / 1e9 }, rgb));
|
||||||
|
|
||||||
|
if(mListener != null) {
|
||||||
|
mHandler.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
mListener.onData(offset, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal number of sample values per second.
|
||||||
|
* Only valid after the first call to {@link ISensor#start}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public double getFps() { return mFps; }
|
||||||
|
|
||||||
|
/** start measurement, resetting sensor data */
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
mData.clear();
|
||||||
|
mDetector.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stop measurement, keeping sensor data */
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
mDetector.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series of each dimension: e.g. {{t1, t2, ...}, {x1, x2, ...}}
|
||||||
|
* Deep copies are returned. Time is seconds in {@link System#nanoTime} timebase.
|
||||||
|
*
|
||||||
|
* @return sensor data recorded since {@link ISensor#start} was called
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<double[]> getSamples() { return mData.getSamples(); }
|
||||||
|
|
||||||
|
/** @return number of samples */
|
||||||
|
@Override
|
||||||
|
public int length() { return mData.length(); }
|
||||||
|
|
||||||
|
/** events are delivered to the main thread, but pixel handling occurs in a separate thread */
|
||||||
|
@Override
|
||||||
|
public void setListener(ISensor.Listener listener) { mListener = listener; }
|
||||||
|
|
||||||
|
/** obtain details after FAILED state */
|
||||||
|
@Override
|
||||||
|
public Exception getError() {
|
||||||
|
// TODO(david) implement me
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/src/main/java/net/heartshield/sensors/SensorData.java
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series signal handling (with ArrayList-like automatic array memory management).
|
||||||
|
* Timebase is always nanoTime.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-03-21
|
||||||
|
*/
|
||||||
|
public class SensorData {
|
||||||
|
/**
|
||||||
|
* Time series of each dimension: e.g. {{t1, t2, ...}, {x1, x2, ...}}
|
||||||
|
* Note that backing arrays may be longer than the actual data contained.
|
||||||
|
*/
|
||||||
|
private List<double[]> mSamples;
|
||||||
|
|
||||||
|
/** actual number of samples */
|
||||||
|
private int mLength;
|
||||||
|
|
||||||
|
private static final int START_BUF_SIZE = 1;
|
||||||
|
|
||||||
|
/** @param ndim number of dimensions, excluding timestamp */
|
||||||
|
SensorData(int ndim) {
|
||||||
|
if(ndim < 1)
|
||||||
|
throw new IllegalArgumentException("ndim must be at least 1");
|
||||||
|
|
||||||
|
mLength = 0;
|
||||||
|
mSamples = new ArrayList<>();
|
||||||
|
for(int i = 0; i < ndim+1; i++)
|
||||||
|
mSamples.add(new double[START_BUF_SIZE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series of each dimension: e.g. {{t1, t2, ...}, {x1, x2, ...}}
|
||||||
|
* Deep copies are returned.
|
||||||
|
*
|
||||||
|
* @return sensor samples recorded since {@link ISensor#start} was called
|
||||||
|
*/
|
||||||
|
synchronized List<double[]> getSamples() {
|
||||||
|
List<double[]> samples = new ArrayList<>();
|
||||||
|
for(int i = 0; i < mSamples.size(); i++) {
|
||||||
|
double[] dim = new double[mLength];
|
||||||
|
System.arraycopy(mSamples.get(i), 0, dim, 0, mLength);
|
||||||
|
samples.add(dim);
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return number of samples */
|
||||||
|
synchronized int length() { return mLength; }
|
||||||
|
|
||||||
|
private void extendIfNeeded(int writeIndex) {
|
||||||
|
int oldLength = mSamples.get(0).length;
|
||||||
|
if(writeIndex < oldLength)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int newLength = oldLength;
|
||||||
|
while(newLength <= writeIndex)
|
||||||
|
newLength *= 2;
|
||||||
|
|
||||||
|
List<double[]> newSamples = new ArrayList<>();
|
||||||
|
|
||||||
|
for(int i = 0; i < mSamples.size(); i++) {
|
||||||
|
double[] dim = new double[newLength];
|
||||||
|
System.arraycopy(mSamples.get(i), 0, dim, 0, mLength);
|
||||||
|
newSamples.add(dim);
|
||||||
|
}
|
||||||
|
mSamples = newSamples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param sample array of single timestamped measurement, e.g. {t, x, y, z} */
|
||||||
|
synchronized void addSample(double[] sample) {
|
||||||
|
if(sample.length != mSamples.size())
|
||||||
|
throw new IllegalArgumentException("a single sample must have sample.length == ndim+1");
|
||||||
|
extendIfNeeded(mLength);
|
||||||
|
for(int i = 0; i < mSamples.size(); i++)
|
||||||
|
mSamples.get(i)[mLength] = sample[i];
|
||||||
|
mLength++;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void clear() {
|
||||||
|
int ndimp1 = mSamples.size();
|
||||||
|
mLength = 0;
|
||||||
|
mSamples.clear();
|
||||||
|
for(int i = 0; i < ndimp1; i++)
|
||||||
|
mSamples.add(new double[START_BUF_SIZE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param startTime seconds in nanoTime timebase
|
||||||
|
*/
|
||||||
|
public static List<double[]> transposeSamples(List<double[]> samples, double startTime) {
|
||||||
|
final int a = samples.get(0).length;
|
||||||
|
final int b = samples.size();
|
||||||
|
List<double[]> result = new ArrayList<>();
|
||||||
|
|
||||||
|
for(int i = 0; i < a; i++) {
|
||||||
|
double[] entry = new double[b];
|
||||||
|
for(int j = 0; j < b; j++)
|
||||||
|
entry[j] = samples.get(j)[i];
|
||||||
|
entry[0] -= startTime;
|
||||||
|
result.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<double[]> transposeSamples(List<double[]> samples) {
|
||||||
|
final int a = samples.get(0).length;
|
||||||
|
final int b = samples.size();
|
||||||
|
List<double[]> result = new ArrayList<>();
|
||||||
|
|
||||||
|
for(int i = 0; i < a; i++) {
|
||||||
|
double[] entry = new double[b];
|
||||||
|
for(int j = 0; j < b; j++)
|
||||||
|
entry[j] = samples.get(j)[i];
|
||||||
|
result.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/src/main/java/net/heartshield/sensors/SensorDataPCM.java
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import net.heartshield.data.WavData;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series signal handling (with ArrayList-like automatic array memory management).
|
||||||
|
*
|
||||||
|
* Builds a WAV file in memory.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-03-31
|
||||||
|
*/
|
||||||
|
public class SensorDataPCM {
|
||||||
|
/**
|
||||||
|
* Note that backing arrays may be longer than the actual data contained.
|
||||||
|
*/
|
||||||
|
private byte[] mBytes;
|
||||||
|
|
||||||
|
/** actual number of bytes */
|
||||||
|
private int mLength;
|
||||||
|
|
||||||
|
private double mStartTime;
|
||||||
|
|
||||||
|
private static final int START_BUF_SIZE = 1;
|
||||||
|
|
||||||
|
SensorDataPCM() {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartTime(double startTime) {
|
||||||
|
mStartTime = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getStartTime() { return mStartTime; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To save RAM, this method returns the underlying buffer - it does NOT deep-copy.
|
||||||
|
*/
|
||||||
|
synchronized ByteBuffer getData() {
|
||||||
|
/*
|
||||||
|
// deep copy to return the actual amount of bytes
|
||||||
|
byte[] arr = new byte[mLength];
|
||||||
|
System.arraycopy(mBytes, 0, arr, 0, mLength);
|
||||||
|
return arr;
|
||||||
|
*/
|
||||||
|
|
||||||
|
return ByteBuffer.wrap(mBytes, 0, mLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return number of bytes, including WAV header data */
|
||||||
|
synchronized int length() { return mLength; }
|
||||||
|
|
||||||
|
/** @return number of bytes of actual PCM data, without WAV header */
|
||||||
|
synchronized int bodyLength() { return mLength - WavData.HEADER_SIZE; }
|
||||||
|
|
||||||
|
private void extendIfNeeded(int writeIndex) {
|
||||||
|
int oldLength = mBytes.length;
|
||||||
|
if(writeIndex < oldLength)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int newLength = oldLength;
|
||||||
|
while(newLength <= writeIndex)
|
||||||
|
newLength *= 2;
|
||||||
|
|
||||||
|
byte[] newBytes = new byte[newLength];
|
||||||
|
//Log.i("SensorDataPCM", "extendIfNeeded(writeIndex=" + writeIndex + ") of oldLength=" + oldLength + " to newLength=" + newLength + " so copying over " + mLength + " old mLength bytes.");
|
||||||
|
System.arraycopy(mBytes, 0, newBytes, 0, mLength);
|
||||||
|
mBytes = newBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*synchronized void addData(byte[] data) {
|
||||||
|
extendIfNeeded(mLength + data.length);
|
||||||
|
System.arraycopy(data, 0, mBytes, mLength, data.length);
|
||||||
|
mLength += data.length;
|
||||||
|
}*/
|
||||||
|
|
||||||
|
synchronized void addData(ByteBuffer data, int nbytes) {
|
||||||
|
extendIfNeeded(mLength + nbytes);
|
||||||
|
data.rewind();
|
||||||
|
data.get(mBytes, mLength, nbytes);
|
||||||
|
mLength += nbytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void clear() {
|
||||||
|
// implicitly reserve WavData header space in the front
|
||||||
|
// note: ugly, and could be reversed and AudioSensor.getPCM() adapted instead.
|
||||||
|
mLength = WavData.HEADER_SIZE;
|
||||||
|
mBytes = new byte[WavData.HEADER_SIZE + START_BUF_SIZE];
|
||||||
|
mStartTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/src/main/java/net/heartshield/sensors/SensorFactory.java
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package net.heartshield.sensors;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.view.TextureView;
|
||||||
|
|
||||||
|
import net.heartshield.control.ReleaseConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-12
|
||||||
|
*/
|
||||||
|
public class SensorFactory implements ISensorFactory {
|
||||||
|
private Activity mActivity;
|
||||||
|
|
||||||
|
public SensorFactory(Activity activity) {
|
||||||
|
mActivity = activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ISensor getAccelerationSensor() {
|
||||||
|
return new AccelerationSensor(mActivity, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IntensityDetector getIntensityDetector(TextureView cameraPreview) {
|
||||||
|
/*
|
||||||
|
* nice-to: benchmark the CPU and choose a resolution which we can process in the
|
||||||
|
* time available between frames (30 ms)
|
||||||
|
*/
|
||||||
|
return new Camera2Detector(mActivity, cameraPreview, 320, 240, /* stickyUiThread */ false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IAudioSensor getAudioSensor() {
|
||||||
|
/*
|
||||||
|
* The event rate below controls the chunk size processed in the filter pipeline.
|
||||||
|
* nice-to: decouple GUI updates from the chunk size being processed.
|
||||||
|
* Instead, use a timer that fetches the required data from a FIFO buffer.
|
||||||
|
*/
|
||||||
|
return ReleaseConfig.RECORD_ECG ? new AudioSensor(2.0) : new DummyAudioSensor();
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/src/main/java/net/heartshield/ui/CircleView.java
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package net.heartshield.ui;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.support.v4.content.ContextCompat;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import net.heartshield.prevent.R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-10-29
|
||||||
|
*/
|
||||||
|
public class CircleView extends View {
|
||||||
|
private double mViewDiameter = 1.0;
|
||||||
|
private double mMeasureSize;
|
||||||
|
private int mWidth;
|
||||||
|
private int mHeight;
|
||||||
|
private Paint mPaint;
|
||||||
|
|
||||||
|
public CircleView(Context context, @Nullable AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init(Context context) {
|
||||||
|
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
mPaint.setColor(ContextCompat.getColor(context, R.color.square_image_background_highlight));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
super.onDraw(canvas);
|
||||||
|
//canvas.drawCircle(x, y, radius, paint);
|
||||||
|
canvas.drawCircle(mWidth / 2, mHeight / 2, (float) (mMeasureSize / 2.0 * (0.2 + mViewDiameter * 0.60)), mPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDiameter(double diameter) {
|
||||||
|
mViewDiameter = diameter;
|
||||||
|
mViewDiameter = Math.min(Math.max(mViewDiameter, 0.0), 1.0);
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
|
/*
|
||||||
|
if(false) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
//final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
|
||||||
|
final int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
|
||||||
|
//final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
|
||||||
|
final int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
|
||||||
|
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||||
|
mMeasureSize = Math.min(widthSpecSize, heightSpecSize);
|
||||||
|
mWidth = widthSpecSize;
|
||||||
|
mHeight = heightSpecSize;
|
||||||
|
|
||||||
|
/*
|
||||||
|
if(widthSpecSize < heightSpecSize) {
|
||||||
|
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
|
||||||
|
mMeasureSize = widthSpecSize;
|
||||||
|
} else {
|
||||||
|
super.onMeasure(heightMeasureSpec, heightMeasureSpec);
|
||||||
|
mMeasureSize = heightSpecSize;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/src/main/java/net/heartshield/ui/LineChartUI.java
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package net.heartshield.ui;
|
||||||
|
|
||||||
|
import android.graphics.Color;
|
||||||
|
|
||||||
|
import com.github.mikephil.charting.charts.LineChart;
|
||||||
|
import com.github.mikephil.charting.components.Legend;
|
||||||
|
import com.github.mikephil.charting.components.XAxis;
|
||||||
|
import com.github.mikephil.charting.components.YAxis;
|
||||||
|
import com.github.mikephil.charting.data.LineData;
|
||||||
|
import com.github.mikephil.charting.data.LineDataSet;
|
||||||
|
import com.github.mikephil.charting.utils.ColorTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements UI look and feel.
|
||||||
|
* Logical methods are in LineChartWrapper directly.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-03-20
|
||||||
|
*/
|
||||||
|
class LineChartUI {
|
||||||
|
LineChart mChart;
|
||||||
|
float mLineWidth;
|
||||||
|
int mLineColor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pass in an existing LineChart from the UI:
|
||||||
|
* <p>
|
||||||
|
* <pre>
|
||||||
|
* new LineChartUI((LineChart) findViewById(R.id.chart))
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
LineChartUI(LineChart chart, float lineWidth, int lineColor) {
|
||||||
|
mChart = chart;
|
||||||
|
mLineWidth = lineWidth;
|
||||||
|
mLineColor = lineColor;
|
||||||
|
|
||||||
|
setupChart();
|
||||||
|
setupData();
|
||||||
|
setupAxes();
|
||||||
|
}
|
||||||
|
|
||||||
|
LineDataSet createDataSet() {
|
||||||
|
|
||||||
|
LineDataSet set = new LineDataSet(null, "Dynamic Data");
|
||||||
|
set.setAxisDependency(YAxis.AxisDependency.LEFT);
|
||||||
|
//set.setColor(ColorTemplate.getHoloBlue());
|
||||||
|
|
||||||
|
set.setColor(mLineColor);
|
||||||
|
|
||||||
|
set.setCircleColor(Color.WHITE);
|
||||||
|
set.setLineWidth(mLineWidth);
|
||||||
|
|
||||||
|
set.setCircleRadius(4f);
|
||||||
|
set.setFillAlpha(65);
|
||||||
|
set.setFillColor(ColorTemplate.getHoloBlue());
|
||||||
|
set.setHighLightColor(Color.rgb(244, 117, 117));
|
||||||
|
set.setValueTextColor(Color.WHITE);
|
||||||
|
set.setValueTextSize(9f);
|
||||||
|
set.setDrawValues(false);
|
||||||
|
|
||||||
|
set.setDrawCircles(false);
|
||||||
|
set.setHighlightEnabled(false);
|
||||||
|
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** general chart look and feel settings */
|
||||||
|
private void setupChart() {
|
||||||
|
// enable description text
|
||||||
|
mChart.getDescription().setEnabled(false);
|
||||||
|
|
||||||
|
// enable touch gestures
|
||||||
|
mChart.setTouchEnabled(true);
|
||||||
|
|
||||||
|
// enable scaling and dragging
|
||||||
|
mChart.setDragEnabled(true);
|
||||||
|
mChart.setScaleEnabled(true);
|
||||||
|
mChart.setDrawGridBackground(false);
|
||||||
|
|
||||||
|
// if disabled, scaling can be done on x- and y-axis separately
|
||||||
|
mChart.setPinchZoom(false);
|
||||||
|
|
||||||
|
// set an alternative background color
|
||||||
|
//mChart.setBackgroundColor(Color.LTGRAY);
|
||||||
|
mChart.setBackgroundColor(Color.WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupData() {
|
||||||
|
LineData data = new LineData();
|
||||||
|
data.setValueTextColor(Color.WHITE);
|
||||||
|
|
||||||
|
data.setHighlightEnabled(false);
|
||||||
|
|
||||||
|
// add empty data
|
||||||
|
mChart.setData(data);
|
||||||
|
|
||||||
|
LineDataSet set = createDataSet();
|
||||||
|
data.addDataSet(set);
|
||||||
|
|
||||||
|
|
||||||
|
// get the legend (only possible after setting data)
|
||||||
|
Legend l = mChart.getLegend();
|
||||||
|
|
||||||
|
l.setEnabled(false);
|
||||||
|
// modify the legend ...
|
||||||
|
//l.setForm(Legend.LegendForm.LINE);
|
||||||
|
//l.setTypeface(mTfLight);
|
||||||
|
//l.setTextColor(Color.WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
float AXIS_MIN = 0f;
|
||||||
|
float AXIS_MAX = 1f;
|
||||||
|
|
||||||
|
private void setupAxes() {
|
||||||
|
XAxis xl = mChart.getXAxis();
|
||||||
|
//xl.setTypeface(mTfLight);
|
||||||
|
xl.setTextColor(Color.WHITE);
|
||||||
|
xl.setDrawGridLines(false);
|
||||||
|
xl.setAvoidFirstLastClipping(true);
|
||||||
|
xl.setEnabled(false);
|
||||||
|
|
||||||
|
YAxis leftAxis = mChart.getAxisLeft();
|
||||||
|
//leftAxis.setTypeface(mTfLight);
|
||||||
|
leftAxis.setTextColor(Color.WHITE);
|
||||||
|
leftAxis.setAxisMaximum(AXIS_MAX);
|
||||||
|
leftAxis.setAxisMinimum(AXIS_MIN);
|
||||||
|
leftAxis.setDrawGridLines(false);
|
||||||
|
leftAxis.setEnabled(false);
|
||||||
|
|
||||||
|
YAxis rightAxis = mChart.getAxisRight();
|
||||||
|
rightAxis.setEnabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/src/main/java/net/heartshield/ui/LineChartWrapper.java
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package net.heartshield.ui;
|
||||||
|
|
||||||
|
import android.graphics.Color;
|
||||||
|
|
||||||
|
import com.github.mikephil.charting.charts.LineChart;
|
||||||
|
import com.github.mikephil.charting.data.Entry;
|
||||||
|
import com.github.mikephil.charting.data.LineData;
|
||||||
|
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages LineChart from MPAndroidChart.
|
||||||
|
*
|
||||||
|
* Logic goes here, bling goes into {@link LineChartUI}.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-03-20
|
||||||
|
*/
|
||||||
|
public class LineChartWrapper extends LineChartUI {
|
||||||
|
private List<Entry> mEntries = new ArrayList<>();
|
||||||
|
|
||||||
|
/** window size displayed by default */
|
||||||
|
public static final float WINDOW_SIZE_SECS = 3.0f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pass in an existing LineChart from the UI:
|
||||||
|
* <p>
|
||||||
|
* <pre>
|
||||||
|
* new LineChartWrapper((LineChart) findViewById(R.id.chart))
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public LineChartWrapper(LineChart chart, float lineWidth, int lineColor) {
|
||||||
|
super(chart, lineWidth, lineColor);
|
||||||
|
|
||||||
|
// axis ranges: x is in secs, y is [AXIS_MIN : AXIS_MAX] == [0.0 : 1.0]
|
||||||
|
mChart.setViewPortOffsets(0.0f, AXIS_MAX, WINDOW_SIZE_SECS, AXIS_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final float LINE_WIDTH = 5.0f;
|
||||||
|
private static final int LINE_COLOR = Color.RED;
|
||||||
|
|
||||||
|
public LineChartWrapper(LineChart chart) {
|
||||||
|
this(chart, LINE_WIDTH, LINE_COLOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace chart data and scroll to it.
|
||||||
|
* Must be called from a drawing UI thread.
|
||||||
|
*/
|
||||||
|
public void replaceData(double[] times, double[] series) {
|
||||||
|
throwIfDataSetNull();
|
||||||
|
if(times.length != series.length)
|
||||||
|
throw new IllegalArgumentException("times.length != series.length");
|
||||||
|
if(times.length == 0)
|
||||||
|
throw new IllegalArgumentException("times.length == 0");
|
||||||
|
|
||||||
|
LineData data = mChart.getData();
|
||||||
|
|
||||||
|
data.getDataSetByIndex(0).clear();
|
||||||
|
|
||||||
|
for(int i = 0; i < times.length; i++) {
|
||||||
|
Entry e = new Entry((float) (times[i] - times[0]), (float) series[i]);
|
||||||
|
data.addEntry(e, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.notifyDataChanged();
|
||||||
|
mChart.notifyDataSetChanged();
|
||||||
|
mChart.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new data point to the chart, and scroll to it.
|
||||||
|
* Must be called from a drawing UI thread.
|
||||||
|
*/
|
||||||
|
public void addEntry(float time, float frame) {
|
||||||
|
throwIfDataSetNull();
|
||||||
|
LineData data = mChart.getData();
|
||||||
|
|
||||||
|
Entry e = new Entry(time, frame);
|
||||||
|
mEntries.add(e);
|
||||||
|
data.addEntry(e, 0);
|
||||||
|
|
||||||
|
// only keep the last few relevant entries around
|
||||||
|
//if(mEntries.size() > 100) {
|
||||||
|
if((time - mEntries.get(0).getX()) > WINDOW_SIZE_SECS) {
|
||||||
|
data.removeEntry(mEntries.get(0), 0);
|
||||||
|
mEntries.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.notifyDataChanged();
|
||||||
|
|
||||||
|
|
||||||
|
// axis ranges: x is in secs, y is [AXIS_MIN : AXIS_MAX] == [0.0 : 1.0]
|
||||||
|
float x1 = -time;
|
||||||
|
float x2 = x1 + WINDOW_SIZE_SECS;
|
||||||
|
|
||||||
|
// viewport is where we draw things (relative to screen coordinates), i.e.
|
||||||
|
// if viewport left is negative, we see later points in time
|
||||||
|
mChart.setViewPortOffsets(x1, AXIS_MAX, x2, AXIS_MIN);
|
||||||
|
|
||||||
|
mChart.notifyDataSetChanged();
|
||||||
|
mChart.invalidate();
|
||||||
|
// << android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
|
||||||
|
|
||||||
|
// this automatically refreshes the chart (calls invalidate())
|
||||||
|
// mChart.moveViewTo(data.getXValCount()-7, 55f,
|
||||||
|
// AxisDependency.LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void throwIfDataSetNull() {
|
||||||
|
LineData data = mChart.getData();
|
||||||
|
if(data == null)
|
||||||
|
throw new IllegalStateException("chart data has not been constructed yet");
|
||||||
|
|
||||||
|
ILineDataSet set = data.getDataSetByIndex(0);
|
||||||
|
if(set == null)
|
||||||
|
throw new IllegalStateException("chart dataset has not been constructed yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/src/main/java/net/heartshield/ui/PermissionHelper.java
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package net.heartshield.ui;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.app.Dialog;
|
||||||
|
import android.app.DialogFragment;
|
||||||
|
import android.content.DialogInterface;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.support.v4.app.ActivityCompat;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import net.heartshield.prevent.R;
|
||||||
|
import net.heartshield.sensors.Camera2Detector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For apps above API 21, Android requires that we flash the user with a runtime permission request
|
||||||
|
* the first time before we use a permission.
|
||||||
|
*
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2016-03-21
|
||||||
|
*/
|
||||||
|
public class PermissionHelper {
|
||||||
|
private static final String TAG = "PermissionHelper";
|
||||||
|
|
||||||
|
private Activity mActivity;
|
||||||
|
private String[] mPermissions;
|
||||||
|
|
||||||
|
public PermissionHelper(Activity activity, String[] permissions) {
|
||||||
|
mActivity = activity;
|
||||||
|
mPermissions = permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** show a permissions request if necessary */
|
||||||
|
public void request() {
|
||||||
|
if(!hasPermissionsGranted()) {
|
||||||
|
requestPermissions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasPermissionsGranted() {
|
||||||
|
for (String permission : mPermissions) {
|
||||||
|
if (ActivityCompat.checkSelfPermission(mActivity, permission)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ConfirmationDialog extends DialogFragment {
|
||||||
|
private Activity mActivity;
|
||||||
|
private String[] mPermissions;
|
||||||
|
|
||||||
|
public ConfirmationDialog() {}
|
||||||
|
|
||||||
|
public void setActivity(Activity activity) { mActivity = activity; }
|
||||||
|
|
||||||
|
public void setPermissions(String[] permissions) {
|
||||||
|
this.mPermissions = permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||||
|
final Activity activity = mActivity;
|
||||||
|
|
||||||
|
return new AlertDialog.Builder(getActivity())
|
||||||
|
.setMessage(R.string.permission_request)
|
||||||
|
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(DialogInterface dialog, int which) {
|
||||||
|
ActivityCompat.requestPermissions(activity, mPermissions, 1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setNegativeButton(android.R.string.cancel,
|
||||||
|
new DialogInterface.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(DialogInterface dialog, int which) {
|
||||||
|
activity.moveTaskToBack(true);
|
||||||
|
android.os.Process.killProcess(android.os.Process.myPid());
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestPermissions() {
|
||||||
|
if (shouldShowRequestPermissionRationale(mPermissions)) {
|
||||||
|
Log.i(TAG, "shouldShowRequestPermissionRationale()=true");
|
||||||
|
|
||||||
|
ConfirmationDialog cd = new ConfirmationDialog();
|
||||||
|
cd.setActivity(mActivity);
|
||||||
|
cd.setPermissions(mPermissions);
|
||||||
|
cd.show(mActivity.getFragmentManager(), "dialog");
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "shouldShowRequestPermissionRationale()=false");
|
||||||
|
ActivityCompat.requestPermissions(mActivity, mPermissions, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldShowRequestPermissionRationale(String[] permissions) {
|
||||||
|
for (String permission : permissions) {
|
||||||
|
if (ActivityCompat.shouldShowRequestPermissionRationale(mActivity, permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
85
app/src/main/java/net/heartshield/util/JsonUtil.java
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package net.heartshield.util;
|
||||||
|
|
||||||
|
import com.google.gson.ExclusionStrategy;
|
||||||
|
import com.google.gson.FieldAttributes;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonSerializationContext;
|
||||||
|
import com.google.gson.JsonSerializer;
|
||||||
|
|
||||||
|
import net.heartshield.data.NoRemote;
|
||||||
|
import net.heartshield.data.TimeZoneAdapter;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author David Madl (git@abanbytes.eu)
|
||||||
|
* @date 2017-04-07
|
||||||
|
*/
|
||||||
|
public class JsonUtil {
|
||||||
|
private static String[] jsonKeys(JSONObject obj) {
|
||||||
|
ArrayList<String> keys = new ArrayList<>();
|
||||||
|
Iterator<String> ita = obj.keys();
|
||||||
|
while(ita.hasNext())
|
||||||
|
keys.add(ita.next());
|
||||||
|
return keys.toArray(new String[keys.size()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JSONObject mergeJson(JSONObject a, JSONObject b) throws JSONException {
|
||||||
|
JSONObject merged = new JSONObject();
|
||||||
|
|
||||||
|
for(String k : jsonKeys(a))
|
||||||
|
merged.put(k, a.get(k));
|
||||||
|
|
||||||
|
for(String k : jsonKeys(b))
|
||||||
|
merged.put(k, b.get(k));
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Target {
|
||||||
|
REMOTE,
|
||||||
|
LOCAL
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Gson getGson() {
|
||||||
|
return getGson(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Gson getGson(Target target) {
|
||||||
|
GsonBuilder builder = new GsonBuilder()
|
||||||
|
.registerTypeAdapter(TimeZone.class, new TimeZoneAdapter())
|
||||||
|
.registerTypeAdapter(Object.class, new JsonSerializer<Object>() {
|
||||||
|
@Override
|
||||||
|
public JsonElement serialize(Object src, Type typeOfSrc, JsonSerializationContext context) {
|
||||||
|
return context.serialize(src);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.excludeFieldsWithModifiers(Modifier.PRIVATE)
|
||||||
|
.setPrettyPrinting();
|
||||||
|
|
||||||
|
if(target == Target.REMOTE) {
|
||||||
|
builder.addSerializationExclusionStrategy(new ExclusionStrategy() {
|
||||||
|
@Override
|
||||||
|
public boolean shouldSkipField(FieldAttributes f) {
|
||||||
|
return (f.getAnnotation(NoRemote.class) != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldSkipClass(Class<?> clazz) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.create();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/src/main/res/drawable-hdpi/jog.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
app/src/main/res/drawable-mdpi/jog.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
app/src/main/res/drawable-xhdpi/jog.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
app/src/main/res/drawable-xxhdpi/jog.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
13
app/src/main/res/drawable/bordered.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="@color/colorBackground"/>
|
||||||
|
<stroke android:width="1dip" android:color="@color/colorPrimaryDark"/>
|
||||||
|
<corners android:bottomLeftRadius="0dp"
|
||||||
|
android:bottomRightRadius="0dp"
|
||||||
|
android:topLeftRadius="0dp"
|
||||||
|
android:topRightRadius="0dp"/>
|
||||||
|
<padding android:bottom="0dip"
|
||||||
|
android:left="0dip"
|
||||||
|
android:right="0dip"
|
||||||
|
android:top="0dip"/>
|
||||||
|
</shape>
|
||||||
BIN
app/src/main/res/drawable/cameras.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
app/src/main/res/drawable/connection_nok.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
app/src/main/res/drawable/connection_ok.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/drawable/female.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
app/src/main/res/drawable/female_dis.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
app/src/main/res/drawable/header.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
app/src/main/res/drawable/heart_nok.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
app/src/main/res/drawable/heart_nok_small.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
app/src/main/res/drawable/heart_ok.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
app/src/main/res/drawable/heart_ok_small.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
app/src/main/res/drawable/heart_only.png
Normal file
|
After Width: | Height: | Size: 51 KiB |