This commit is contained in:
2026-03-02 06:19:34 +01:00
commit d83a7506d6
273 changed files with 364230 additions and 0 deletions

View 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;
}
}

View 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();
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View 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
};
}
}
}

View 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);
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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);
}
}

View 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;
}
}

View 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();
}

View File

@@ -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
}
}

View 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));
}
}

File diff suppressed because it is too large Load Diff

View 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));
}
}

View 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);
}
}

View 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;
}
}

View File

@@ -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; }
}

View File

@@ -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
}
}

View File

@@ -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() {
}
}

View 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 {}

View 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());
}
}

View 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
}
}

View 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;
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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();
}
}
}
}

View 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();
}
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View 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();
}
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View 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) {
}
};
}

View 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()???
}
});
}
}

View 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();
}
}

View 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);
}
}

View File

@@ -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);
}
}

View 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();
}
}
}
}

View 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();
}
}

View 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) {
}
};
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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);
//}
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View 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;
}
}

View 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; }
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View File

@@ -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;
}
}

View 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
}
}

View File

@@ -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);
}

View 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();
}

View File

@@ -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();
}

View 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);
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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;
}
*/
}
}

View 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);
}
}

View 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");
}
}

View 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;
}
}

View 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();
}
}