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 |