Compare commits
19 Commits
9ac505fc07
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e9c2b7c15d | |||
| 671a6d6fb4 | |||
| 09a17f3623 | |||
| ecc1804519 | |||
| df543bc3bc | |||
| b2d9db1b16 | |||
| 3bf922fcc0 | |||
| 28641c538a | |||
|
|
1851fa76e6 | ||
|
|
ab2c2e69e6 | ||
|
|
f24e9f2040 | ||
|
|
4212860dc1 | ||
|
|
4d9c3009ce | ||
|
|
0f55f105f8 | ||
|
|
9892aa43f4 | ||
|
|
24744abb80 | ||
|
|
d2c1cb0ae3 | ||
|
|
5f8d6c0585 | ||
|
|
f8c8ba6bf1 |
378
APP_STRUCTURE.md
Normal file
378
APP_STRUCTURE.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# OpenSeizureDetector Android App Structure
|
||||
|
||||
This document gives new contributors a fast, high-level map of how the Android application is organised: the major Java classes, resource folders, the startup / shutdown lifecycle, and the data + alarm flow.
|
||||
|
||||
## 1. Top-Level Overview
|
||||
OpenSeizureDetector is an Android foreground-service based application that:
|
||||
- Collects motion (acceleration) and physiological (heart rate, optionally SpO₂) data from a wearable (Garmin, Pebble, BLE devices, phone sensors, etc.).
|
||||
- Analyses incoming samples to detect tonic–clonic seizure patterns.
|
||||
- Raises local (audible) and remote (SMS / phone call) alarms and optionally shares anonymised data with a central server.
|
||||
- Provides a swipe-based main UI (MainActivity2 + fragments) and a startup checklist screen (StartupActivity) to ensure prerequisites are satisfied before normal operation.
|
||||
|
||||
Core runtime logic lives in the `SdServer` foreground service; Activities and Fragments mostly visualize status and manipulate preferences.
|
||||
|
||||
## 2. Startup Lifecycle (Happy Path)
|
||||
1. User taps the launcher icon -> Android launches `StartupActivity` (declared with MAIN/LAUNCHER intent filter in `AndroidManifest.xml`).
|
||||
2. `StartupActivity`:
|
||||
- Applies default preference values from XML (alarm, general, datasource, logging, etc.).
|
||||
- Requests / validates required runtime permissions (notifications, SMS, location, Bluetooth, activity recognition, etc.).
|
||||
- Starts (or restarts) the foreground service `SdServer` via `OsdUtil.startServer()` if not already running.
|
||||
- Binds to the service through `SdServiceConnection` to monitor status (watch connection, settings received, data flowing).
|
||||
- Displays a checklist (ProgressBars + TextViews) updated by a periodic timer until all conditions are OK.
|
||||
- When all OK, transitions to either `MainActivity2` (new UI) or legacy `MainActivity` depending on the `UseNewUi` preference.
|
||||
3. `SdServer.onStartCommand()`:
|
||||
- Calls `updatePrefs()`; selects concrete `SdDataSource*` implementation based on `DataSource` preference.
|
||||
- Instantiates and starts the chosen data source (e.g., `SdDataSourceGarmin`, `SdDataSourceBLE`, `SdDataSourcePhone`, etc.).
|
||||
- Initialises logging (`LogManager`), location (`LocationFinder` if SMS alarms enabled), timers, embedded HTTP server (`SdWebServer`), wake lock, and notification channels; enters foreground (persistent notification).
|
||||
- Begins receiving data; populates `SdData` and runs analysis algorithms (e.g., seizure + heart rate) to update alarm state.
|
||||
4. `MainActivity2` binds to the already running `SdServer` to present live status via Fragments.
|
||||
|
||||
## 3. Shutdown Lifecycle
|
||||
There are several ways the service stops:
|
||||
- User selects an "Exit" / stop option (menu action triggers `OsdUtil.stopServer()`), which calls `stopService` for `SdServer`.
|
||||
- System kills the service (low memory or user force-stop) -> `SdServer.onDestroy()` releases wake lock, stops data source, timers, web server, and cleans up.
|
||||
- Device reboot triggers `BootBroadcastReceiver` which, if `AutoStart` preference is true, launches `StartupActivity` to restart.
|
||||
|
||||
## 4. Major Java Components
|
||||
### Activities
|
||||
- `StartupActivity`: Launcher activity; initial permission + readiness checklist; starts/binds the service; routes to main UI.
|
||||
- `MainActivity2`: Modern, swipe-based interface using `ViewPager2` + Fragments; shows system/algorithm status, data sharing, web server info, heart rate, ML algorithm, battery, watch signal etc.
|
||||
- `MainActivity`: Legacy UI retained for backward compatibility (optionally used if `UseNewUi` is false).
|
||||
- `PrefActivity`: Preferences editor (headers + fragments defined in `res/xml/*prefs.xml`).
|
||||
- `BLEScanActivity`: Discovers and selects BLE devices when using BLE/BLE2 data source.
|
||||
- `AuthenticateActivity`: Handles login for data sharing / remote API.
|
||||
- `LogManagerControlActivity`, `ExportDataActivity`, `RemoteDbActivity`: Data sharing, viewing, exporting, pruning local DB.
|
||||
- `ReportSeizureActivity`, `EditEventActivity`: Manual event reporting / editing.
|
||||
|
||||
### Foreground Service
|
||||
- `SdServer`: Heart of the application. Manages:
|
||||
- Data source selection and life-cycle.
|
||||
- Alarm evaluation (seizure, heart rate, fall, etc.).
|
||||
- Notification updates (different channels for service status, events, data sharing issues).
|
||||
- SMS & phone call alarm orchestration (timers to allow cancellation).
|
||||
- Logging & data sharing upload scheduling.
|
||||
- Embedded HTTP server (`SdWebServer`) for local access.
|
||||
- Wake lock to keep CPU active during monitoring.
|
||||
|
||||
### Data Sources (inherit from / similar pattern to `SdDataSource`)
|
||||
Each encapsulates a communication protocol + parsing logic:
|
||||
- `SdDataSourcePebble` – Legacy Pebble watch integration.
|
||||
- `SdDataSourceAw` – Android Wear devices.
|
||||
- `SdDataSourceGarmin` – Garmin watch app (acceleration + heart rate streams).
|
||||
- `SdDataSourceBLE` / `SdDataSourceBLE2` – Generic BLE device integrations (v1 / v2 protocols for devices like PineTime / BangleJS).
|
||||
- `SdDataSourceNetwork` – Pulls detector data over network from a remote instance.
|
||||
- `SdDataSourcePhone` – Uses phone onboard sensors as the detector.
|
||||
|
||||
### Algorithms & Data Structures
|
||||
- `SdData`: Aggregates current sample values, processing results, and metadata (data source name, versions, etc.).
|
||||
- `SdAlgHr`: Heart rate alarm algorithms (simple threshold, adaptive, average-based).
|
||||
- `SdAlgNn`: Neural network / machine learning based seizure detection (see `FragmentMlAlg` for UI).
|
||||
- `CircBuf`: Circular buffer used for windowed averaging and historical metrics.
|
||||
|
||||
#### Core Analysis Pipeline (`SdDataSource.doAnalysis()`)
|
||||
The principal seizure detection loop lives in the protected method `SdDataSource.doAnalysis()`, called each time a fresh window of accelerometer samples arrives (vector magnitude or derived from 3D data):
|
||||
1. Phone/watch battery percentages are updated and appended to rolling buffers.
|
||||
2. (Currently hard-coded) sample frequency set (e.g. 25 Hz) and frequency resolution derived from window length (`mNsamp`).
|
||||
3. FFT performed on the raw acceleration window using JTransforms `DoubleFFT_1D.realForward`.
|
||||
4. Overall spectrum "power" (`specPower`) accumulated up to a cutoff frequency (`FreqCutoff`), zeroing higher bins to reduce noise.
|
||||
5. Region of Interest (ROI) defined by preferences `AlarmFreqMin`/`AlarmFreqMax`; average ROI power (`roiPower`) and the ratio `roiRatio = 10 * roiPower / specPower` computed.
|
||||
6. Simplified spectrum (`simpleSpec[]`) built in 1 Hz bins for UI visualisation (scaled by `ACCEL_SCALE_FACTOR` to align with historic Pebble scaling).
|
||||
7. Populates fields in `mSdData` (timestamps, `specPower`, `roiPower`, thresholds, ROI freq bounds, simplified spectrum array, flags) and marks `haveData`.
|
||||
8. If the CNN alarm feature is enabled (`mCnnAlarmActive`) then `nnAnalysis()` updates `mPseizure` probability.
|
||||
9. Secondary narrow-band motion check via `flapCheck()` (arm flapping detection) producing a boolean fed into `alarmCheck()`.
|
||||
10. `alarmCheck()` applies thresholds (`AlarmThresh`, `AlarmRatioThresh`) and enabled algorithm flags (classic OSD, flap, CNN) to set alarm cause/state.
|
||||
11. Additional modalities processed: `hrCheck()` (heart rate alarms / frozen HR detection), `o2SatCheck()` (oxygen saturation), `fallCheck()` (fall detection), and `muteCheck()` (user-induced mute logic).
|
||||
12. Result dispatched upstream via `mSdDataReceiver.onSdDataReceived(mSdData)` (SdServer consumes to raise notifications / alarms).
|
||||
|
||||
Key preferences influencing `doAnalysis()`:
|
||||
- `AlarmFreqMin` / `AlarmFreqMax`: ROI frequency band.
|
||||
- `AlarmThresh` / `AlarmRatioThresh`: Power and ratio thresholds for alarm state.
|
||||
- Flap detection thresholds (`FlapThresh`, `FlapRatioThresh`, min/max flap band) when flap alarm active.
|
||||
- Flags enabling algorithms: `mOsdAlarmActive`, `mFlapAlarmActive`, `mCnnAlarmActive`.
|
||||
|
||||
Performance / Extension Notes:
|
||||
- Current implementation re-allocates FFT arrays each call; optimisation could reuse buffers.
|
||||
- Sample frequency is hard-coded (25 Hz) inside analysis; aligning it with dynamic settings from watch would improve fidelity.
|
||||
- Multiple ROIs could be generalised (current flapCheck duplicates spectral processing).
|
||||
- Scaling (`ACCEL_SCALE_FACTOR`) is applied post-hoc; future refactor could normalise early and adopt floating-point consistently for UI.
|
||||
|
||||
#### `doAnalysis()` Invocation & Alarm Propagation
|
||||
`doAnalysis()` is not called in a tight loop by the service; instead each concrete `SdDataSource` decides when a complete window of samples is ready and then invokes it:
|
||||
- BLE (`SdDataSourceBLE` / `SdDataSourceBLE2`): Acceleration notifications fill a raw buffer (`rawData` length 125 = 5s @25Hz). When full, the datasource copies buffered samples into `mSdData`, sets `mNsamp`, calls `doAnalysis()`, then sends a one–byte alarm state back to the device via a status GATT characteristic.
|
||||
- Phone (`SdDataSourcePhone`): Collects accelerometer sensor events, performs crude downsampling from ~50Hz to 25Hz. Once `rawData` is full it triggers `doAnalysis()`, resets counters and continues.
|
||||
- Pebble (`SdDataSourcePebble`): The watch app performs analysis on-device and sends already processed results (including `alarmState`, spectrum) – so `doAnalysis()` is NOT used for Pebble; received data directly calls `mSdDataReceiver.onSdDataReceived`.
|
||||
- Network (`SdDataSourceNetwork`): Fetches remote JSON; if successful passes parsed `SdData` upward. Remote faults set `alarmState` to NET FAULT (7). Local `doAnalysis()` not used.
|
||||
- Garmin (`SdDataSourceGarmin`): Similar pattern (buffer fill -> `doAnalysis()`).
|
||||
|
||||
After `doAnalysis()` completes in a source that uses it:
|
||||
1. Spectral metrics (`roiPower`, `specPower`, simplified spectrum) and timing fields are populated.
|
||||
2. `flapCheck()` optionally computes a narrow-band flap detection boolean.
|
||||
3. `alarmCheck(flapDetected)` applies power & ratio thresholds and accumulates time in alarm (`mAlarmCount += mSamplePeriod`) to transition through:
|
||||
- OK (0) -> WARNING (1) after `mWarnTime` seconds of continuous in-alarm condition.
|
||||
- WARNING (1) -> ALARM (2) after `mAlarmTime` seconds.
|
||||
- Recovery logic: leaving in-alarm state downgrades from ALARM (2) to WARNING (1) (simulating a just-entered warning), or from WARNING (1) to OK (0).
|
||||
4. Other modality checks may elevate alarmState:
|
||||
- `hrCheck()`: If any heart rate alarm stands (simple / adaptive / average) sets `alarmState = 2` and appends cause tags (`HR`, `HR_ADAPT`, `HR_AVG`). Null HR may either cause alarm or fault depending on `mHRNullAsAlarm`.
|
||||
- `o2SatCheck()`: Low or null oxygen saturation (with null-as-alarm enabled) sets standing flags and may escalate to ALARM.
|
||||
- `fallCheck()`: Sets `fallAlarmStanding` and may signal FALL alarm state (3).
|
||||
- `muteCheck()`: Watch/user mute sets `alarmState = 6` (MUTE) overriding other transient states.
|
||||
- Fault timers (`faultCheck()` elsewhere) may set FAULT (4) or NET FAULT (7).
|
||||
5. The datasource calls `mSdDataReceiver.onSdDataReceived(mSdData)` (implemented by `SdServer`).
|
||||
|
||||
##### Alarm State Codes (as observed in code)
|
||||
| Code | Meaning | Origin / Trigger |
|
||||
|------|---------|------------------|
|
||||
| 0 | OK | No current alarm condition or post-recovery. |
|
||||
| 1 | WARNING | Thresholds exceeded for > `warnTime` but < `alarmTime`. |
|
||||
| 2 | ALARM | Thresholds exceeded for > `alarmTime`, or HR/O₂/fall promoted, or HR adaptive/average thresholds stand. |
|
||||
| 3 | FALL | Fall detection logic sets `fallAlarmStanding` or explicit fall state. |
|
||||
| 4 | FAULT | Internal fault (e.g., missing data, HR sensor failure without null-as-alarm). |
|
||||
| 5 | MANUAL ALARM | Raised manually (e.g., `SdServer.raiseManualAlarm()`). |
|
||||
| 6 | MUTE | User/watch initiated mute; prevents audible alarm but maintains monitoring. |
|
||||
| 7 | NET FAULT | Network datasource error / fault condition (`SdDataSourceNetwork`). |
|
||||
|
||||
##### How `SdServer` Reacts (`onSdDataReceived`)
|
||||
`SdServer.onSdDataReceived(sdData)` interprets `alarmState` plus standing flags and performs side-effects:
|
||||
- OK (0): Clears `alarmStanding` unless latched (`mLatchAlarms`) from previous alarm or fall.
|
||||
- MUTE (6): Sets phrase "MUTE", suppresses alarms and notifications severity.
|
||||
- WARNING (1): Plays warning tone (`warningBeep()`), logs (if enabled), updates notification to warning channel/state.
|
||||
- ALARM (2) or MANUAL ALARM (5): Sets phrase "ALARM", raises `alarmStanding`, plays alarm tone (`alarmBeep()`), shows main UI, posts high-severity notification, initiates latch timer (`startLatchTimer()`), and sends SMS / phone alarms if enabled (rate-limited to one per minute).
|
||||
- FALL (3 or `fallAlarmStanding` true): Behaves similarly to ALARM but with phrase "FALL" (alarms + SMS sending). Fall may remain standing until cleared.
|
||||
- HR / O₂ / Adaptive HR / Average HR: These set `alarmState = 2` when standing; `alarmCause` accumulates tokens; downstream handling identical to ALARM.
|
||||
- FAULT (4, 7, HR fault, frozen HR fault): Plays fault warning beep (`faultWarningBeep()`), shows fault notification; may attempt datasource restart after timer (auto-restart currently disabled for BLE2 to prevent duplicate notifications).
|
||||
|
||||
##### Latching & Reset
|
||||
With `mLatchAlarms` enabled, returning to OK does not immediately clear previous ALARM/FALL states; user must manually accept/reset (e.g., via UI actions) or wait for latch timer expiry (`mLatchAlarmTimer`). Without latching, state machine freely transitions downwards.
|
||||
|
||||
##### Data Sharing & Logging Post-Analysis
|
||||
Upon each received dataset, `SdServer` updates internal `mSdData`, pushes it to `SdWebServer` for external viewing, and passes it to `LogManager` (`mLm.updateSdData(mSdData)`), which may create/append local events (especially on transitions into ALARM states) and schedule remote uploads.
|
||||
|
||||
##### Device Feedback
|
||||
BLE/BLE2 write a single-byte alarm state back to the watch/device after analysis (`executeWriteCharacteristic(mStatusChar, statusVal)` or peripheral write) enabling haptic / on-watch UI feedback.
|
||||
Pebble handles its own alarm transitions internally before sending results.
|
||||
|
||||
This separation lets wearable implementations stay lightweight (simple streaming) while centralizing threshold timing, multi-modal fusion, and alarm escalation logic on the phone (except for Pebble legacy analysis).
|
||||
|
||||
### UI Fragments (used in `MainActivity2` ViewPager)
|
||||
- `FragmentCommon`: Overall status & key indicators.
|
||||
- `FragmentOsdAlg`: Seizure algorithm metrics (spectrum ratio, thresholds, raw/processed values).
|
||||
- `FragmentHrAlg`: Heart rate algorithm status & thresholds.
|
||||
- `FragmentMlAlg`: ML model results / confidence scores.
|
||||
- `FragmentBatt`: Watch + phone battery status.
|
||||
- `FragmentSystem`: System info (permissions, service state, logging flags).
|
||||
- `FragmentWatchSig`: Signal quality / connectivity indicators.
|
||||
- `FragmentWebServer`: Local web server URL / status.
|
||||
- `FragmentDataSharing`: Data sharing setup state, counts of local vs remote events.
|
||||
|
||||
### Utilities & Helpers
|
||||
- `OsdUtil`: Starts/stops/binds the service; permission checks; logging helpers; system/environment utilities.
|
||||
- `LogManager`: Handles local + remote logging, event packaging, pruning, and upload scheduling.
|
||||
- `MlModelManager`: Manages loading / inference of ML models (if in use).
|
||||
- `LocationFinder`: Acquires GPS coordinates for SMS alarms.
|
||||
- `WebApiConnection` / `WebApiConnection_firebase` / `WebApiConnection_osdapi`: Remote data sharing / API integrations.
|
||||
- `SdServiceConnection`: Wraps service binding / connection callbacks, exposes convenience methods (`watchConnected()`, `hasSdData()`, `hasSdSettings()`).
|
||||
- `BootBroadcastReceiver`: Auto-start on device boot when preference enabled.
|
||||
- `GattAttributes`: BLE UUID constants and attribute names.
|
||||
- `OsdUncaughtExceptionHandler`: Crash reporting path (uses UCE Handler library).
|
||||
- `SdWebServer`: Lightweight embedded HTTP server (for local status / data access).
|
||||
|
||||
### Data Sharing Module
|
||||
Under `uk/org/openseizuredetector/data/...`:
|
||||
- Repository pattern for authentication: `LoginRepository`, `LoginDataSource`, `LoggedInUser`, `Result` (standard wrapper around success/error).
|
||||
|
||||
## 5. Resource Folder Structure
|
||||
```
|
||||
res/
|
||||
layout/ Activity & Fragment UI XML (e.g., startup_activity, activity_main2, fragment_*).
|
||||
menu/ Action bar & overflow menus (e.g., main_activity_actions.xml).
|
||||
values/ Strings (`strings.xml`), styles, colors, dimensions; base resources.
|
||||
values-XX/ Localized strings (de, es, pl, ru, sl, sv, etc.).
|
||||
drawable/ Icons and graphics (e.g., star_of_life_48x48). Might also include vector assets.
|
||||
xml/ Preference definition files and network security config:
|
||||
- alarm_prefs.xml
|
||||
- basic_prefs.xml
|
||||
- general_prefs.xml
|
||||
- logging_prefs.xml
|
||||
- pebble_datasource_prefs.xml
|
||||
- network_datasource_prefs.xml
|
||||
- network_passive_datasource_prefs.xml
|
||||
- seizure_detector_prefs.xml
|
||||
- preference_headers.xml (groups preferences)
|
||||
- network_security_config.xml
|
||||
```
|
||||
Other notable folders:
|
||||
- `assets/` (if present) – Additional static assets (not heavily used here).
|
||||
- `libs/` – Third-party JARs (e.g., FFT / chart libraries) bundled with the app.
|
||||
|
||||
## 6. Preferences Flow
|
||||
1. XML files under `res/xml` define keys and defaults.
|
||||
2. `StartupActivity.onCreate()` calls `PreferenceManager.setDefaultValues(...)` for each preference file (once per install/version).
|
||||
3. Classes such as `OsdUtil`, `SdServer`, `SdAlgHr` invoke `updatePrefs()` to read `SharedPreferences` (
|
||||
`PreferenceManager.getDefaultSharedPreferences(context)`), caching operational parameters (thresholds, window lengths, flags).
|
||||
4. Preference changes may trigger service restarts or algorithm behavior changes (e.g., enabling SMS alarms requires location permission and `LocationFinder`).
|
||||
|
||||
## 7. Data & Alarm Flow
|
||||
```
|
||||
Wearable / Phone Sensors --> Concrete SdDataSource --> SdServer (receives callbacks) -->
|
||||
Algorithms (SdAlgNn, SdAlgHr, fall detection, etc.) --> Alarm State Transitions -->
|
||||
Audible ToneGenerator / MP3 playback
|
||||
Foreground Notification Updates
|
||||
Timed SMS / Phone Call Alerts (with cancellation window)
|
||||
Data Logging (local DB) / Remote Sharing (LogManager, WebApiConnection*)
|
||||
Web Server exposure (SdWebServer)
|
||||
```
|
||||
Heart rate buffering uses `CircBuf` windows for simple/adaptive thresholding; seizure analysis (frequency spectrum, ratio thresholds) executed inside data source analysis routines (see respective `SdDataSource*` classes).
|
||||
|
||||
## 8. Foreground Service & Resilience
|
||||
- Service runs in foreground with a persistent notification (required for stable long-running monitoring on modern Android).
|
||||
- Wake lock prevents CPU sleep during monitoring sessions (battery intensive but improves reliability).
|
||||
- Timers manage periodic tasks (event validation checks, remote upload scheduling, alarm muting windows).
|
||||
- Boot auto-start via broadcast receiver ensures continuity if user opted in.
|
||||
|
||||
## 9. Adding a New Data Source (High-Level Guide)
|
||||
1. Create a new `SdDataSource<YourDevice>` class implementing the expected interface / callback pattern (see existing sources for template).
|
||||
2. Handle connection, authentication/handshake, data parsing, and call back into `SdServer` with new samples.
|
||||
3. Add a selection case in `SdServer.onStartCommand()` for your `DataSource` preference string.
|
||||
4. Provide any additional preferences XML (e.g., update period, device address) and add them to default initialization in `StartupActivity`.
|
||||
5. Update UI fragments if device supplies new metrics.
|
||||
|
||||
## 10. Where to Look for Key Logic
|
||||
- Service lifecycle & alarm orchestration: `SdServer.java` (`onStartCommand`, `onDestroy`, timers, notifications).
|
||||
- Startup readiness checklist: `StartupActivity.serverStatusRunnable`.
|
||||
- Data source selection: `SdServer.onStartCommand()` switch over `mSdDataSourceName`.
|
||||
- Heart rate algorithms: `SdAlgHr.java`.
|
||||
- ML / seizure algorithm UI: `FragmentMlAlg.java` + `SdAlgNn.java`.
|
||||
- Permission checks: `StartupActivity` & `OsdUtil` (Bluetooth, activity recognition, SMS, location).
|
||||
- Logging & data sharing: `LogManager.java`, `RemoteDbActivity.java`, `FragmentDataSharing.java`.
|
||||
- Embedded web server logic: `SdWebServer.java`.
|
||||
|
||||
## 11. Key Preference Examples (Selected)
|
||||
| Preference Key | Purpose |
|
||||
| -------------- | ------- |
|
||||
| `DataSource` | Selects which device source (Pebble, Garmin, BLE, Phone, Network). |
|
||||
| `AlarmThresh` / `AlarmRatioThresh` | Seizure detection thresholds (spectrum amplitude / ratio). |
|
||||
| `HRThreshMin` / `HRThreshMax` | Simple heart rate alarm bounds. |
|
||||
| `HRAdaptiveAlarmWindowSecs` | Window size for adaptive HR average buffering. |
|
||||
| `SMSAlarm` / `PhoneCallAlarm` | Enable remote alerts. |
|
||||
| `LogData` / `LogDataRemote` | Enable local logging vs remote data sharing. |
|
||||
| `UseNewUi` | Switch between legacy and modern main UI. |
|
||||
| `AutoStart` | Auto-launch on device boot. |
|
||||
|
||||
(See `res/xml/*_prefs.xml` for full list.)
|
||||
|
||||
## 12. Notifications & Timers
|
||||
- Multiple channels for service status and events (IDs inside `SdServer`).
|
||||
- Timers: `FaultTimer`, `CheckEventsTimer`, SMS countdown (`SmsTimer`), latch alarm timer, etc., each controlling asynchronous transitions.
|
||||
|
||||
## 13. Data Sharing Flow
|
||||
1. User authenticates (`AuthenticateActivity`) -> obtains token stored in preferences.
|
||||
2. `LogManager` packages events (timestamped, with retention pruning) and attempts periodic uploads (`remoteLogPeriod`).
|
||||
3. Unvalidated remote events prompt UI reminders (`FragmentDataSharing`).
|
||||
|
||||
## 14. Crash Handling
|
||||
`UCEHandler` integrated in Activities and Service to capture uncaught exceptions and offer sending logs via email.
|
||||
|
||||
## 15. Embedded Web Server
|
||||
`SdWebServer` exposes (read-only) status / logged data for local network access; started automatically by `SdServer` after data source initialisation.
|
||||
|
||||
## 16. Stopping / Restarting Service Programmatically
|
||||
- Stop: `OsdUtil.stopServer()` -> calls `stopService(Intent(SdServer))`.
|
||||
- Start: `OsdUtil.startServer()` -> `Context.startForegroundService(...)` (on modern Android) then service builds notification & begins monitoring.
|
||||
- Restart triggered implicitly if critical permissions change (logic can call stop/start to reinitialise components).
|
||||
|
||||
## 17. Extending Alarms / Algorithms
|
||||
To add new alarm logic (e.g., oxygen saturation):
|
||||
1. Introduce algorithm class (`SdAlgO2` style) storing buffers and thresholds.
|
||||
2. Update data source parsing to capture new metric.
|
||||
3. Integrate into `SdServer` evaluation loop; amend notification text generation.
|
||||
4. Provide preference keys + XML + UI Fragment display.
|
||||
|
||||
## 18. Glossary
|
||||
- "Latch Alarm": Alarm remains active until explicitly reset (even if underlying condition clears) for a configured duration.
|
||||
- "Adaptive HR Alarm": Builds a moving average; raises alarm when HR deviates beyond +/- configurable delta.
|
||||
- "Foreground Service": Long-lived component with persistent notification; less likely to be killed.
|
||||
- "Data Sharing": User-consented upload of anonymised seizure events / sensor data to central server for algorithm improvement.
|
||||
|
||||
## 19. Useful Entry Points for Debugging
|
||||
- Set breakpoints in `SdServer.onStartCommand()` to inspect data source initialisation.
|
||||
- Use logs emitted via `OsdUtil.writeToSysLogFile` to trace state transitions.
|
||||
- Inspect `StartupActivity.serverStatusRunnable` for readiness gating issues.
|
||||
|
||||
## 20. Related Documents
|
||||
- `README.md`: General project overview, build instructions.
|
||||
- `DEV_NOTES.txt`: Developer notes / historical comments.
|
||||
- `BLE_Datasource_Specification.md`: Protocol specifics for BLE devices.
|
||||
- PDFs under `doc/` for algorithm assessment and app structure diagrams.
|
||||
|
||||
## 21. End-to-End Data -> Alarm Flow Diagram
|
||||
Below are two complementary diagrams (ASCII and Mermaid) showing how a data window travels from the wearable to an alarm being raised.
|
||||
|
||||
PNG Version: See `FLOW_DIAGRAM.png` in the repository root for a downloadable image.
|
||||
|
||||
ASCII Flow
|
||||
```
|
||||
Wearable Sensors (Accel / HR / O₂)
|
||||
|
|
||||
v
|
||||
Watch Firmware / Device App
|
||||
| (Pebble: does analysis + sends results)
|
||||
| (BLE/Garmin/PineTime/etc.: streams raw samples)
|
||||
v
|
||||
SdDataSource (Buffer + Parse + Downsample/Scale)
|
||||
| (Collect ~125 samples = 5s @25Hz)
|
||||
v (window full)
|
||||
doAnalysis()
|
||||
|-> FFT (JTransforms) & Spectrum Power (specPower)
|
||||
|-> ROI Power & Ratio (roiPower / roiRatio)
|
||||
|-> Simplified Spectrum (simpleSpec[])
|
||||
|-> flapCheck() narrow band detection
|
||||
|-> nnAnalysis() (if CNN enabled)
|
||||
|-> hrCheck(), o2SatCheck(), fallCheck(), muteCheck()
|
||||
v
|
||||
Populate mSdData (specPower, roiPower, simpleSpec, alarmState, alarmCause, metrics)
|
||||
v
|
||||
mSdDataReceiver.onSdDataReceived(mSdData)
|
||||
v
|
||||
SdServer.onSdDataReceived()
|
||||
|-> Alarm state machine (OK/WARNING/ALARM/FALL/FAULT/MUTE)
|
||||
|-> Latching logic (startLatchTimer if ALARM)
|
||||
|-> Notifications (foreground + event channels)
|
||||
|-> Tones (warningBeep / alarmBeep / faultWarningBeep)
|
||||
|-> SMS / Phone Call (rate-limited, if enabled)
|
||||
|-> Logging & Data Sharing (LogManager update, remote upload scheduling)
|
||||
|-> Web Server update (SdWebServer.setSdData)
|
||||
|-> Write alarmState byte back to device (BLE/Garmin)
|
||||
v
|
||||
UI Fragments / Web Server / Remote API Consumers
|
||||
```
|
||||
|
||||
Mermaid Diagram (optional rendering if supported):
|
||||
```mermaid
|
||||
flowchart TD
|
||||
W[Wearable Sensors\n(Accel / HR / O₂)] --> WF[Watch Firmware / Device App]
|
||||
WF --> DS{SdDataSource\nBuffer & Parse}
|
||||
DS -->|Window Full| AN[doAnalysis()]
|
||||
AN --> FFT[FFT + Spectrum]
|
||||
FFT --> ROI[ROI Power & Ratio]
|
||||
ROI --> ALG[flapCheck / nnAnalysis / hrCheck / o2SatCheck / fallCheck / muteCheck]
|
||||
ALG --> SD[SdData Populated\n(alarmState, metrics)]
|
||||
SD --> RCV[mSdDataReceiver.onSdDataReceived]
|
||||
RCV --> SRV[SdServer.onSdDataReceived]
|
||||
SRV --> ACT[Alarm Actions\nNotification / Tone / SMS / Phone / Latch / Log]
|
||||
SRV --> FEED[Write alarmState\nback to Device]
|
||||
ACT --> UI[UI Fragments / Web Server / Data Sharing]
|
||||
WF -. Pebble path (analysis on watch) .-> SD
|
||||
```
|
||||
Notes:
|
||||
- Pebble path bypasses local `doAnalysis()`; analysis executes on the watch and sets `alarmState` before dispatch.
|
||||
- Network datasource substitutes "Buffer & Parse" with remote JSON fetch and may directly set NET FAULT (7) on failure.
|
||||
- Latching prevents immediate clearing of ALARM/FALL states until user intervention or timer expiry.
|
||||
|
||||
## 22. Related Documents
|
||||
- `README.md`: General project overview, build instructions.
|
||||
- `DEV_NOTES.txt`: Developer notes / historical comments.
|
||||
- `BLE_Datasource_Specification.md`: Protocol specifics for BLE devices.
|
||||
- PDFs under `doc/` for algorithm assessment and app structure diagrams.
|
||||
|
||||
---
|
||||
Questions / Improvements: Feel free to open issues or pull requests on GitHub.
|
||||
@@ -1,5 +1,11 @@
|
||||
OpenSeizureDetector Android App - Change Log
|
||||
============================================
|
||||
V4.3.1 - Fixed corrupted user interface issues on Android 15 and Android 16.
|
||||
V4.3.0 - 2025-07-16
|
||||
- Added support for Android 15 (API 35) to allow publishing on Play Store.
|
||||
- Improved the data sharing screen to show grouped events to reduce the number of events that need to be edited.
|
||||
V4.2.12 - Fixed crash when pressing 'Install Watch App' button by hiding the button if the Pebble data source is not selected
|
||||
- Added a 'Help' and 'Troubleshooting' button and menu item to draw users' attention to the web site instructions.
|
||||
V4.2.11 - Updated permissions handling to support Android 14 (needed to publish on Play Store)
|
||||
- added a crude 'flap' detector into OSD Algorithm
|
||||
- Added setting to change the delay before SMS alert is sent (Issue #202)
|
||||
|
||||
BIN
FLOW_DIAGRAM.png
Normal file
BIN
FLOW_DIAGRAM.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -5,6 +5,8 @@ This repository contains the source code for the main
|
||||
Android App, which is published on
|
||||
(Google Play Store)[https://play.google.com/store/apps/details?id=uk.org.openseizuredetector].
|
||||
|
||||
For a detailed architectural overview (activities, service, data flow, resources) see APP_STRUCTURE.md.
|
||||
|
||||
This seizure detector uses a Garmin smart watch to collect movement (acceleration) and heart rate data which is used to detect tonic-clonic epileptic seizures.
|
||||
See (the OpenSeizureDetector Web Site)[https://www.openseizuredetector.org.uk/] for more details.
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
android {
|
||||
compileSdk 34 // Android 14
|
||||
compileSdk 36
|
||||
// Android 14
|
||||
useLibrary 'org.apache.http.legacy'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "uk.org.openseizuredetector"
|
||||
minSdkVersion 23 // Android 6
|
||||
targetSdkVersion 34 // Android 14 = 34
|
||||
targetSdkVersion 35 // Android 15 = 35
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "1234",
|
||||
"project_id": "osd-data-sharing",
|
||||
"storage_bucket": "osd-data-sharing.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "xxxx",
|
||||
"android_client_info": {
|
||||
"package_name": "uk.org.openseizuredetector"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "xxxx",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "uk.org.openseizuredetector",
|
||||
"certificate_hash": "xxx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "xxx",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "xxx"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "xxx",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:versionCode="149"
|
||||
android:versionName="4.2.11e">
|
||||
android:versionCode="154"
|
||||
android:versionName="4.3.1">
|
||||
<!-- android:allowBackup="false" -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity2"
|
||||
android:exported="false" />
|
||||
<activity android:name=".SensorValidationActivity" />
|
||||
<!--<activity
|
||||
android:name=".MlModelManager"
|
||||
android:exported="false"
|
||||
|
||||
@@ -5,10 +5,13 @@ import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.TextView;
|
||||
@@ -38,6 +41,7 @@ public class EditEventActivity extends AppCompatActivity {
|
||||
private String mEventTypeStr = null;
|
||||
private String mEventSubTypeStr = null;
|
||||
private String mEventId;
|
||||
private ArrayList<String> mEventIds; // For group editing
|
||||
private String mEventNotes = "";
|
||||
//private Date mEventDateTime;
|
||||
private RadioGroup mEventTypeRg;
|
||||
@@ -52,6 +56,22 @@ public class EditEventActivity extends AppCompatActivity {
|
||||
Log.v(TAG, "onCreate()");
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_edit_event);
|
||||
// Handle system window insets for all API levels
|
||||
View rootView = findViewById(R.id.root_layout_edit_event);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
|
||||
// Get the system bar insets
|
||||
int top = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top;
|
||||
int bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;
|
||||
|
||||
// Apply padding to your main content view
|
||||
LinearLayout content = findViewById(R.id.edit_event_content_layout);
|
||||
content.setPadding(0, top, 0, bottom);
|
||||
|
||||
// Return the insets so they keep propagating
|
||||
return WindowInsetsCompat.CONSUMED;
|
||||
});
|
||||
|
||||
|
||||
mUtil = new OsdUtil(getApplicationContext(), serverStatusHandler);
|
||||
mConnection = new SdServiceConnection(getApplicationContext());
|
||||
|
||||
@@ -61,8 +81,15 @@ public class EditEventActivity extends AppCompatActivity {
|
||||
|
||||
Bundle extras = getIntent().getExtras();
|
||||
if (extras != null) {
|
||||
String eventId = extras.getString("eventId");
|
||||
mEventId = eventId;
|
||||
mEventIds = extras.getStringArrayList("eventIds");
|
||||
if (mEventIds != null && !mEventIds.isEmpty()) {
|
||||
Log.v(TAG, "onCreate - Group Edit - eventIds=" + mEventIds.toString());
|
||||
mEventId = mEventIds.get(0);
|
||||
} else {
|
||||
Log.v(TAG, "onCreate - Single Edit - eventId=" + extras.getString("eventId"));
|
||||
mEventId = extras.getString("eventId");
|
||||
mEventIds = null;
|
||||
}
|
||||
Log.v(TAG, "onCreate - mEventId=" + mEventId);
|
||||
}
|
||||
|
||||
@@ -297,6 +324,7 @@ public class EditEventActivity extends AppCompatActivity {
|
||||
}
|
||||
Log.v(TAG, "onOK() - eventObj=" + mEventObj.toString());
|
||||
|
||||
// First we just save the open event, irrespective of whether it is a group edit or not.
|
||||
try {
|
||||
mWac.updateEvent(mEventObj, new WebApiConnection.JSONObjectCallback() {
|
||||
@Override
|
||||
@@ -320,9 +348,58 @@ public class EditEventActivity extends AppCompatActivity {
|
||||
mUtil.showToast("Error Updating Event");
|
||||
updateUi();
|
||||
}
|
||||
|
||||
// If this is a group edit, we need to update the other events in the group.
|
||||
if (mEventIds != null && mEventIds.size() > 1) {
|
||||
Log.v(TAG, "onOK() - Group Edit - updating other events in group");
|
||||
updateGroupEventsSequentially(0);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
private void updateGroupEventsSequentially(final int index) {
|
||||
if (mEventIds == null || index >= mEventIds.size()) {
|
||||
Log.v(TAG, "updateGroupEventsSequentially - All events updated");
|
||||
return;
|
||||
}
|
||||
final String eventId = mEventIds.get(index);
|
||||
mWac.getEvent(eventId, new WebApiConnection.JSONObjectCallback() {
|
||||
@Override
|
||||
public void accept(JSONObject eventObj) {
|
||||
if (eventObj == null) {
|
||||
Log.e(TAG, "updateGroupEventsSequentially - ERROR: could not retrieve event " + eventId);
|
||||
mUtil.showToast("Error Retrieving Event " + eventId);
|
||||
updateGroupEventsSequentially(index + 1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
eventObj.put("id", eventId);
|
||||
eventObj.put("type", mEventObj.getString("type"));
|
||||
eventObj.put("subType", mEventObj.getString("subType"));
|
||||
eventObj.put("desc", mEventObj.getString("desc"));
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "updateGroupEventsSequentially - ERROR: " + e.getMessage());
|
||||
updateGroupEventsSequentially(index + 1);
|
||||
return;
|
||||
}
|
||||
mWac.updateEvent(eventObj, new WebApiConnection.JSONObjectCallback() {
|
||||
@Override
|
||||
public void accept(JSONObject updatedObj) {
|
||||
if (updatedObj == null) {
|
||||
Log.e(TAG, "updateGroupEventsSequentially - ERROR: update failed for " + eventId);
|
||||
mUtil.showToast("Error Updating Event " + eventId);
|
||||
} else {
|
||||
Log.v(TAG, "updateGroupEventsSequentially - Updated event " + eventId + " OK");
|
||||
mUtil.showToast("Event " + eventId + " Updated OK");
|
||||
}
|
||||
updateGroupEventsSequentially(index + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
RadioGroup.OnCheckedChangeListener onEventTypeChange =
|
||||
new RadioGroup.OnCheckedChangeListener() {
|
||||
|
||||
@@ -13,6 +13,8 @@ import android.os.AsyncTask;
|
||||
import android.os.Handler;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
@@ -22,6 +24,7 @@ import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.DatePicker;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TimePicker;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
@@ -83,6 +86,21 @@ public class ExportDataActivity extends AppCompatActivity
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_dbquery);
|
||||
// Handle system window insets for all API levels
|
||||
View rootView = findViewById(R.id.root_layout_export_data);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
|
||||
// Get the system bar insets
|
||||
int top = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top;
|
||||
int bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;
|
||||
|
||||
// Apply padding to your main content view
|
||||
LinearLayout content = findViewById(R.id.export_data_content_layout);
|
||||
content.setPadding(0, top, 0, bottom);
|
||||
|
||||
// Return the insets so they keep propagating
|
||||
return WindowInsetsCompat.CONSUMED;
|
||||
});
|
||||
|
||||
|
||||
mHandler = new Handler();
|
||||
mUtil = new OsdUtil(this, mHandler);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package uk.org.openseizuredetector;
|
||||
|
||||
import org.jtransforms.fft.DoubleFFT_1D;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Standalone FFT-based frequency validation helper.
|
||||
*
|
||||
* This deliberately does not call SdDataSource.doAnalysis(), because doAnalysis() also performs
|
||||
* alarm, fall, HR, O2, and notification checks. This class reuses the same JTransforms FFT style
|
||||
* used by SdDataSource, but only returns measurement/validation values.
|
||||
*/
|
||||
public class FrequencyValidationAnalyzer {
|
||||
public static final double DEFAULT_SAMPLE_RATE_HZ = 25.0;
|
||||
public static final double DEFAULT_MAX_DISPLAY_FREQ_HZ = 10.0;
|
||||
public static final double DEFAULT_DISPLAY_BIN_WIDTH_HZ = 0.5;
|
||||
public static final double DEFAULT_MIN_PEAK_FREQ_HZ = 0.5;
|
||||
|
||||
public static class SpectrumBar {
|
||||
public final double freqStartHz;
|
||||
public final double freqEndHz;
|
||||
public final double power;
|
||||
|
||||
SpectrumBar(double freqStartHz, double freqEndHz, double power) {
|
||||
this.freqStartHz = freqStartHz;
|
||||
this.freqEndHz = freqEndHz;
|
||||
this.power = power;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return String.format(Locale.UK, "%.1f-%.1f Hz", freqStartHz, freqEndHz);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Result {
|
||||
public double expectedHz;
|
||||
public double measuredHz;
|
||||
public double errorHz;
|
||||
public double errorPercent;
|
||||
public double tolerancePercent;
|
||||
public double sampleRateHz;
|
||||
public int sampleCount;
|
||||
public double durationSeconds;
|
||||
public double peakPower;
|
||||
public boolean pass;
|
||||
public ArrayList<SpectrumBar> displaySpectrum = new ArrayList<>();
|
||||
public double[] rawSamples;
|
||||
}
|
||||
|
||||
public static Result analyze(double[] rawSamples,
|
||||
double sampleRateHz,
|
||||
double expectedHz,
|
||||
double tolerancePercent) {
|
||||
if (rawSamples == null || rawSamples.length < 4) {
|
||||
throw new IllegalArgumentException("Not enough samples for FFT analysis");
|
||||
}
|
||||
if (sampleRateHz <= 0) sampleRateHz = DEFAULT_SAMPLE_RATE_HZ;
|
||||
if (tolerancePercent <= 0) tolerancePercent = 5.0;
|
||||
|
||||
Result result = new Result();
|
||||
result.expectedHz = expectedHz;
|
||||
result.tolerancePercent = tolerancePercent;
|
||||
result.sampleRateHz = sampleRateHz;
|
||||
result.sampleCount = rawSamples.length;
|
||||
result.durationSeconds = rawSamples.length / sampleRateHz;
|
||||
result.rawSamples = rawSamples.clone();
|
||||
|
||||
double mean = 0.0;
|
||||
for (double sample : rawSamples) {
|
||||
mean += sample;
|
||||
}
|
||||
mean = mean / rawSamples.length;
|
||||
|
||||
double[] fft = new double[rawSamples.length * 2];
|
||||
for (int i = 0; i < rawSamples.length; i++) {
|
||||
// Remove the static/gravity/DC component. This makes the validation peak clearer.
|
||||
double centered = rawSamples[i] - mean;
|
||||
|
||||
// Apply a Hann window. This reduces spectral leakage at the edges of the 30/60 sec
|
||||
// validation window and helps prevent very low-frequency drift from dominating.
|
||||
double window = 0.5 * (1.0 - Math.cos((2.0 * Math.PI * i) / (rawSamples.length - 1)));
|
||||
fft[i] = centered * window;
|
||||
}
|
||||
|
||||
DoubleFFT_1D fftDo = new DoubleFFT_1D(rawSamples.length);
|
||||
fftDo.realForward(fft);
|
||||
|
||||
int maxUsefulBin = Math.min(rawSamples.length / 2 - 1,
|
||||
Math.max(1, (int) Math.floor(DEFAULT_MAX_DISPLAY_FREQ_HZ * rawSamples.length / sampleRateHz)));
|
||||
|
||||
// For validation, do not simply pick the strongest bin from the entire spectrum.
|
||||
// Hand/watch motion often has slow drift below 0.5 Hz that can be stronger than the
|
||||
// simulated test frequency. Instead, look near the expected frequency and ignore
|
||||
// near-DC bins. This is for calibration/measurement, not detection.
|
||||
double searchHalfWidthHz;
|
||||
if (expectedHz > 0) {
|
||||
searchHalfWidthHz = Math.max(0.75, expectedHz * tolerancePercent / 100.0 * 2.0);
|
||||
} else {
|
||||
searchHalfWidthHz = DEFAULT_MAX_DISPLAY_FREQ_HZ;
|
||||
}
|
||||
|
||||
double searchMinHz = expectedHz > 0
|
||||
? Math.max(DEFAULT_MIN_PEAK_FREQ_HZ, expectedHz - searchHalfWidthHz)
|
||||
: DEFAULT_MIN_PEAK_FREQ_HZ;
|
||||
double searchMaxHz = expectedHz > 0
|
||||
? Math.min(DEFAULT_MAX_DISPLAY_FREQ_HZ, expectedHz + searchHalfWidthHz)
|
||||
: DEFAULT_MAX_DISPLAY_FREQ_HZ;
|
||||
|
||||
int startBin = Math.max(1, (int) Math.ceil(searchMinHz * rawSamples.length / sampleRateHz));
|
||||
int endBin = Math.min(maxUsefulBin, (int) Math.floor(searchMaxHz * rawSamples.length / sampleRateHz));
|
||||
if (endBin < startBin) {
|
||||
startBin = Math.max(1, (int) Math.ceil(DEFAULT_MIN_PEAK_FREQ_HZ * rawSamples.length / sampleRateHz));
|
||||
endBin = maxUsefulBin;
|
||||
}
|
||||
|
||||
int dominantBin = startBin;
|
||||
double dominantPower = 0.0;
|
||||
for (int i = startBin; i <= endBin; i++) {
|
||||
double power = getMagnitude(fft, i);
|
||||
if (power > dominantPower) {
|
||||
dominantPower = power;
|
||||
dominantBin = i;
|
||||
}
|
||||
}
|
||||
|
||||
result.measuredHz = dominantBin * sampleRateHz / rawSamples.length;
|
||||
result.peakPower = dominantPower;
|
||||
result.errorHz = result.measuredHz - expectedHz;
|
||||
if (expectedHz != 0) {
|
||||
result.errorPercent = 100.0 * result.errorHz / expectedHz;
|
||||
} else {
|
||||
result.errorPercent = 0.0;
|
||||
}
|
||||
result.pass = Math.abs(result.errorPercent) <= tolerancePercent;
|
||||
|
||||
int displayBins = (int) Math.ceil(DEFAULT_MAX_DISPLAY_FREQ_HZ / DEFAULT_DISPLAY_BIN_WIDTH_HZ);
|
||||
for (int b = 0; b < displayBins; b++) {
|
||||
double fStart = b * DEFAULT_DISPLAY_BIN_WIDTH_HZ;
|
||||
double fEnd = fStart + DEFAULT_DISPLAY_BIN_WIDTH_HZ;
|
||||
int binStart = Math.max(1, (int) Math.ceil(fStart * rawSamples.length / sampleRateHz));
|
||||
int binEnd = Math.max(binStart, (int) Math.floor(fEnd * rawSamples.length / sampleRateHz));
|
||||
binEnd = Math.min(binEnd, rawSamples.length / 2 - 1);
|
||||
|
||||
double powerSum = 0.0;
|
||||
int count = 0;
|
||||
for (int i = binStart; i <= binEnd; i++) {
|
||||
powerSum += getMagnitude(fft, i);
|
||||
count++;
|
||||
}
|
||||
double averagePower = count > 0 ? powerSum / count : 0.0;
|
||||
result.displaySpectrum.add(new SpectrumBar(fStart, fEnd, averagePower));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same magnitude calculation style as SdDataSource.getMagnitude().
|
||||
*/
|
||||
private static double getMagnitude(double[] fft, int i) {
|
||||
return (fft[2 * i] * fft[2 * i]) + (fft[2 * i + 1] * fft[2 * i + 1]);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public class LogManager {
|
||||
private boolean mLogRemote;
|
||||
private boolean mLogRemoteMobile;
|
||||
private String mAuthToken;
|
||||
static private SQLiteDatabase mOsdDb = null; // SQLite Database for data and log entries.
|
||||
static public SQLiteDatabase mOsdDb = null; // SQLite Database for data and log entries.
|
||||
private RemoteLogTimer mRemoteLogTimer;
|
||||
private boolean mLogNDA;
|
||||
public NDATimer mNDATimer;
|
||||
@@ -328,7 +328,7 @@ public class LogManager {
|
||||
* Write data to local database
|
||||
* FIXME - I am sure we should not be using raw SQL Srings to do this!
|
||||
*/
|
||||
public void writeDatapointToLocalDb(SdData sdData) {
|
||||
public void writeDatapointToLocalDb(SdData sdData, long alarmDuration) {
|
||||
//Log.v(TAG, "writeDatapointToLocalDb()");
|
||||
Date curDate = new Date();
|
||||
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
@@ -355,7 +355,9 @@ public class LogManager {
|
||||
|
||||
if (sdData.alarmState != 0) {
|
||||
Log.i(TAG, "writeDatapointToLocalDb(): adding event to local DB");
|
||||
createLocalEvent(dateStr, sdData.alarmState, null, null, null, sdData.toSettingsJSON());
|
||||
createLocalEvent(dateStr, sdData.alarmState, "seizure", null,
|
||||
"Duration: " + alarmDuration + "s HR: " + sdData.mHR,
|
||||
sdData.toSettingsJSON());
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
Log.e(TAG, "writeToLocalDb(): Error Writing Data: " + e.toString());
|
||||
@@ -566,7 +568,7 @@ public class LogManager {
|
||||
//long endDateMillis = currentDateMillis - 3600*1000* mDataRetentionPeriod; // Using hours rather than days for testing
|
||||
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
String endDateStr = dateFormat.format(new Date(endDateMillis));
|
||||
String[] tableNames = new String[]{mDpTableName, mEventsTableName};
|
||||
String[] tableNames = new String[]{mDpTableName};
|
||||
for (String tableName : tableNames) {
|
||||
Log.i(TAG, "pruneLocalDb - pruning table " + tableName);
|
||||
try {
|
||||
|
||||
@@ -12,11 +12,15 @@ import android.os.CountDownTimer;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.MenuCompat;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
@@ -42,18 +46,23 @@ import java.lang.reflect.Field;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public class LogManagerControlActivity extends AppCompatActivity {
|
||||
private String TAG = "LogManagerControlActivity";
|
||||
private static final long GROUPING_WINDOW_MINUTES = 3;
|
||||
private static final long GROUPING_WINDOW_MS = GROUPING_WINDOW_MINUTES * 60 * 1000;
|
||||
private LogManager mLm;
|
||||
private Context mContext;
|
||||
private UiTimer mUiTimer;
|
||||
private ArrayList<HashMap<String, String>> mEventsList;
|
||||
private ArrayList<HashMap<String, String>> mRemoteEventsList;
|
||||
private ArrayList<ArrayList<HashMap<String, String>>> mGroupedRemoteEventsList; // Each item is a list of event objects, similar to mRemoteEventsList
|
||||
private ArrayList<HashMap<String, String>> mSysLogList;
|
||||
private SdServiceConnection mConnection;
|
||||
private OsdUtil mUtil;
|
||||
@@ -62,6 +71,7 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
private Integer mUiTimerPeriodSlow = 60000; // 60 seconds - once data has been received and UI populated we only update once per minute.
|
||||
private boolean mUpdateSysLog = true;
|
||||
private Menu mMenu;
|
||||
private CheckBox mGroupEventsCb; // Declare the CheckBox member
|
||||
//private Integer UI_MODE_LOCAL = 0;
|
||||
//private Integer UI_MODE_SHARED = 1;
|
||||
//private Integer mUiMode = UI_MODE_SHARED;
|
||||
@@ -83,6 +93,20 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
|
||||
setContentView(R.layout.activity_log_manager_control);
|
||||
|
||||
// Handle system window insets for all API levels
|
||||
View rootView = findViewById(R.id.root_layout_log_manager_control);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
|
||||
// Get the system bar insets
|
||||
int top = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top;
|
||||
int bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;
|
||||
|
||||
// Apply padding to your main content view
|
||||
LinearLayout content = findViewById(R.id.log_manager_control_content_layout); // Add this ID to your LinearLayout
|
||||
content.setPadding(0, top, 0, bottom);
|
||||
|
||||
// Return the insets so they keep propagating
|
||||
return WindowInsetsCompat.CONSUMED;
|
||||
});
|
||||
/* Force display of overflow menu - from stackoverflow
|
||||
* "how to force use of..."
|
||||
*/
|
||||
@@ -117,6 +141,21 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
(CheckBox) findViewById(R.id.include_nda_cb);
|
||||
includeNDACb.setOnCheckedChangeListener(onIncludeNDACb);
|
||||
|
||||
mGroupEventsCb = findViewById(R.id.group_events_cb);
|
||||
mGroupEventsCb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
// When the checkbox state changes, re-process and update the UI
|
||||
if (mRemoteEventsList != null && !mRemoteEventsList.isEmpty()) {
|
||||
if (isChecked) {
|
||||
createGroupedEventsList();
|
||||
}
|
||||
updateUi(); // Update UI to reflect grouped or non-grouped list
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
ListView lv = (ListView) findViewById(R.id.eventLogListView);
|
||||
lv.setOnItemClickListener(onEventListClick);
|
||||
|
||||
@@ -218,7 +257,7 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
updateUi();
|
||||
});
|
||||
} else {
|
||||
Log.e(TAG,"ERROR: initialiseServiceConnection() - mLm is null");
|
||||
Log.e(TAG, "ERROR: initialiseServiceConnection() - mLm is null");
|
||||
mUtil.showToast(getString(R.string.error_failed_to_start_log_manager));
|
||||
}
|
||||
}
|
||||
@@ -226,6 +265,7 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
|
||||
private void getRemoteEvents(boolean includeWarnings, boolean includeNDA) {
|
||||
mRemoteEventsList = null; // clear existing data
|
||||
mGroupedRemoteEventsList = null;
|
||||
// Retrieve events from remote database
|
||||
mLm.mWac.getEvents((JSONObject remoteEventsObj) -> {
|
||||
Log.v(TAG, "getRemoteEvents()");
|
||||
@@ -252,7 +292,7 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
String dataTime = "null";
|
||||
if (!eventObj.isNull("dataTime")) {
|
||||
dataTime = eventObj.getString("dataTime");
|
||||
Log.v(TAG, "getRemoteEvents() - dataTime=" + dataTime);
|
||||
//Log.v(TAG, "getRemoteEvents() - dataTime=" + dataTime);
|
||||
}
|
||||
String typeStr = "null";
|
||||
if (!eventObj.isNull("type")) {
|
||||
@@ -281,18 +321,142 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
Log.v(TAG, "getRemoteEvents - skipping warning or NDA record");
|
||||
}
|
||||
}
|
||||
Log.v(TAG, "getRemoteEvents() - set mRemoteEventsList(). Updating UI");
|
||||
|
||||
// Sort the remote events list by date, descending (newest first)
|
||||
Log.v(TAG, "getRemoteEvents() - Sorting mRemoteEventsList by date");
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); // Adjust format if needed
|
||||
Collections.sort(mRemoteEventsList, (event1, event2) -> {
|
||||
try {
|
||||
String dt1Str = event1.get("dataTime");
|
||||
String dt2Str = event2.get("dataTime");
|
||||
if (
|
||||
dt1Str == null
|
||||
|| dt2Str == null
|
||||
|| dt1Str.equals("null")
|
||||
|| dt2Str.equals("null"))
|
||||
return 0;
|
||||
Date date1 = sdf.parse(dt1Str);
|
||||
Date date2 = sdf.parse(dt2Str);
|
||||
return date2.compareTo(date1); // Descending
|
||||
} catch (ParseException e) {
|
||||
Log.e(TAG, "Error parsing date for sorting: " + e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
if (mGroupEventsCb.isChecked()) { // Check if grouping is enabled
|
||||
createGroupedEventsList();
|
||||
Log.v(TAG, "getRemoteEvents() - created grouped events. Updating UI");
|
||||
} else {
|
||||
mGroupedRemoteEventsList = null; // Ensure grouped list is cleared if not used
|
||||
Log.v(TAG, "getRemoteEvents() - grouping disabled. Updating UI with flat list.");
|
||||
}
|
||||
updateUi();
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "getRemoteEvents(): Error Parsing remoteEventsObj: " + e.getMessage());
|
||||
mUtil.showToast("Error Parsing remoteEventsObj - this should not happen!!!");
|
||||
mRemoteEventsList = null;
|
||||
mGroupedRemoteEventsList = null;
|
||||
updateUi(); // Update UI to show error state
|
||||
}
|
||||
//Log.v(TAG, "getRemoteEvents(): mRemoteEventsList = " + mRemoteEventsList.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* createGroupedEventsList()
|
||||
* Reads the complete list of remote events mRemoteEventsList and creates a new list mGroupedRemoteEventsList
|
||||
* where each item is a list of events that comprise a group based on time (all events within a 3 minute period are grouped together).
|
||||
*/
|
||||
private void createGroupedEventsList() {
|
||||
Log.i(TAG, "createGroupedEventsList()");
|
||||
/**
|
||||
* createGroupedEventsList()
|
||||
* Reads the complete list of remote events mRemoteEventsList (sorted newest first)
|
||||
* and creates a new list mGroupedRemoteEventsList
|
||||
* where each item is a list of events that comprise a group based on time.
|
||||
*/
|
||||
mGroupedRemoteEventsList = new ArrayList<>();
|
||||
if (mRemoteEventsList == null || mRemoteEventsList.isEmpty()) {
|
||||
Log.i(TAG, "createGroupedEventsList() - mRemoteEventsList is null or empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to parse date strings to long timestamps.
|
||||
// Adjust the SimpleDateFormat pattern to match your "dataTime" format.
|
||||
// If "dataTime" is already a timestamp (long), you can use it directly.
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); // Example format
|
||||
|
||||
ArrayList<HashMap<String, String>> currentGroup = null;
|
||||
long lastEventTimeInGroup = 0;
|
||||
|
||||
for (HashMap<String, String> event : mRemoteEventsList) {
|
||||
String dataTimeString = event.get("dataTime");
|
||||
if (dataTimeString == null || dataTimeString.equals("null")) {
|
||||
Log.w(TAG, "Event has null or invalid dataTime: " + event.get("id"));
|
||||
continue; // Skip events with no valid time
|
||||
}
|
||||
|
||||
long currentEventTime;
|
||||
try {
|
||||
Date eventDate = sdf.parse(dataTimeString);
|
||||
if (eventDate == null) {
|
||||
Log.w(TAG, "Could not parse dataTime: " + dataTimeString + " for event: " + event.get("id"));
|
||||
continue;
|
||||
}
|
||||
currentEventTime = eventDate.getTime();
|
||||
} catch (ParseException e) {
|
||||
Log.e(TAG, "Error parsing date string: " + dataTimeString + " - " + e.getMessage());
|
||||
continue; // Skip if date can't be parsed
|
||||
}
|
||||
|
||||
if (currentGroup == null || (lastEventTimeInGroup - currentEventTime) > GROUPING_WINDOW_MS) {
|
||||
// Start a new group
|
||||
if (currentGroup != null) {
|
||||
moveFirstAlarmToFront(currentGroup); // Move the first ALARM event to the front of the group)
|
||||
mGroupedRemoteEventsList.add(currentGroup);
|
||||
}
|
||||
currentGroup = new ArrayList<>();
|
||||
currentGroup.add(event);
|
||||
lastEventTimeInGroup = currentEventTime;
|
||||
} else {
|
||||
// Add to the current group
|
||||
currentGroup.add(event);
|
||||
// lastEventTimeInGroup remains the time of the first event added to this group (newest)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last group if it exists
|
||||
if (currentGroup != null && !currentGroup.isEmpty()) {
|
||||
moveFirstAlarmToFront(currentGroup); // Move the first ALARM event to the front of the group
|
||||
mGroupedRemoteEventsList.add(currentGroup);
|
||||
}
|
||||
|
||||
Log.i(TAG, "createGroupedEventsList() - Grouped " + mRemoteEventsList.size() +
|
||||
" events into " + mGroupedRemoteEventsList.size() + " groups.");
|
||||
}
|
||||
|
||||
/**
|
||||
* moveFirstAlarmToFront() - This method checks the group for the first
|
||||
* event with an ALARM state (osdAlarmState = 2) and makes that event the
|
||||
* first in the list.
|
||||
*
|
||||
* @param group An ArrayList of HashMaps representing a group of events.
|
||||
*/
|
||||
private void moveFirstAlarmToFront(ArrayList<HashMap<String, String>> group) {
|
||||
//Log.i(TAG, "moveFirstAlarmToFront() - Checking group of size: " + group.size());
|
||||
for (int i = 0; i < group.size(); i++) {
|
||||
HashMap<String, String> event = group.get(i);
|
||||
String alarmStateStr = event.get("osdAlarmState");
|
||||
if (alarmStateStr != null && alarmStateStr.equals("2")) { // ALARM is 2
|
||||
//Log.v(TAG," moveFirstAlarmToFront() - Found ALARM event at index: " + i);
|
||||
if (i != 0) {
|
||||
group.remove(i);
|
||||
group.add(0, event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateUi() {
|
||||
Log.i(TAG, "updateUi()");
|
||||
@@ -342,13 +506,27 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
pb.setIndeterminate(false);
|
||||
pb.setVisibility(View.INVISIBLE);
|
||||
ListView lv = (ListView) findViewById(R.id.remoteEventsLv);
|
||||
ListAdapter adapter = new RemoteEventsAdapter(LogManagerControlActivity.this, mRemoteEventsList, R.layout.log_entry_layout_remote,
|
||||
new String[]{"id", "dataTime", "type", "subType", "osdAlarmStateStr", "desc"},
|
||||
new int[]{R.id.event_id_remote_tv, R.id.event_date_remote_tv, R.id.event_type_remote_tv, R.id.event_subtype_remote_tv,
|
||||
R.id.event_alarmState_remote_tv, R.id.event_notes_remote_tv});
|
||||
lv.setAdapter(adapter);
|
||||
//Log.i(TAG,"adapter[0]="+adapter.getItem(0));
|
||||
//Log.i(TAG,"adapter[3]="+adapter.getItem(3));
|
||||
|
||||
if (mGroupEventsCb.isChecked() && mGroupedRemoteEventsList != null) {
|
||||
// Show only the first event of each group
|
||||
ArrayList<HashMap<String, String>> displayList = new ArrayList<>();
|
||||
for (ArrayList<HashMap<String, String>> group : mGroupedRemoteEventsList) {
|
||||
displayList.add(group.get(0));
|
||||
}
|
||||
ListAdapter adapter = new RemoteEventsAdapter(LogManagerControlActivity.this, displayList, R.layout.log_entry_layout_remote,
|
||||
new String[]{"id", "dataTime", "type", "subType", "osdAlarmStateStr", "desc"},
|
||||
new int[]{R.id.event_id_remote_tv, R.id.event_date_remote_tv, R.id.event_type_remote_tv, R.id.event_subtype_remote_tv,
|
||||
R.id.event_alarmState_remote_tv, R.id.event_notes_remote_tv});
|
||||
lv.setAdapter(adapter);
|
||||
} else if (mRemoteEventsList != null) {
|
||||
ListAdapter adapter = new RemoteEventsAdapter(LogManagerControlActivity.this, mRemoteEventsList, R.layout.log_entry_layout_remote,
|
||||
new String[]{"id", "dataTime", "type", "subType", "osdAlarmStateStr", "desc"},
|
||||
new int[]{R.id.event_id_remote_tv, R.id.event_date_remote_tv, R.id.event_type_remote_tv, R.id.event_subtype_remote_tv,
|
||||
R.id.event_alarmState_remote_tv, R.id.event_notes_remote_tv});
|
||||
lv.setAdapter(adapter);
|
||||
} else {
|
||||
Log.i(TAG, "UpdateUi: No Remote Events");
|
||||
}
|
||||
} else {
|
||||
//mUtil.showToast("No Remote Events");
|
||||
Log.i(TAG, "UpdateUi: No Remote Events");
|
||||
@@ -645,15 +823,26 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
new AdapterView.OnItemClickListener() {
|
||||
public void onItemClick(AdapterView<?> adapter, View v, int position, long id) {
|
||||
Log.v(TAG, "onItemClicKListener() - Position=" + position + ", id=" + id);// Confirmation dialog based on: https://stackoverflow.com/a/12213536/2104584
|
||||
HashMap<String, String> eventObj = (HashMap<String, String>) adapter.getItemAtPosition(position);
|
||||
String eventId = eventObj.get("uploaded");
|
||||
Log.d(TAG, "onItemClickListener(): eventId=" + eventId + ", eventObj=" + eventObj);
|
||||
if (eventId != null) {
|
||||
|
||||
if (mGroupEventsCb.isChecked() && mGroupedRemoteEventsList != null) {
|
||||
Log.v(TAG,"onItemClickListener() - Creating Grouped Events List from Position=" + position);
|
||||
// Get the group for this position
|
||||
ArrayList<HashMap<String, String>> group = mGroupedRemoteEventsList.get(position);
|
||||
ArrayList<String> eventIds = new ArrayList<>();
|
||||
for (HashMap<String, String> event : group) {
|
||||
Log.v(TAG,"onItemClickListener() - Adding event to edit list: " + event.get("id"));
|
||||
eventIds.add(event.get("id"));
|
||||
}
|
||||
Intent i = new Intent(getApplicationContext(), EditEventActivity.class);
|
||||
i.putStringArrayListExtra("eventIds", eventIds);
|
||||
startActivity(i);
|
||||
} else {
|
||||
Log.v(TAG,"onItemClickListener() - Editing Single event at Position=" + position);
|
||||
HashMap<String, String> eventObj = (HashMap<String, String>) adapter.getItemAtPosition(position);
|
||||
String eventId = eventObj.get("id");
|
||||
Intent i = new Intent(getApplicationContext(), EditEventActivity.class);
|
||||
i.putExtra("eventId", eventId);
|
||||
startActivity(i);
|
||||
} else {
|
||||
mUtil.showToast("You Must Wait for Event to Upload before Editing it");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -662,13 +851,31 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
new AdapterView.OnItemClickListener() {
|
||||
public void onItemClick(AdapterView<?> adapter, View v, int position, long id) {
|
||||
Log.v(TAG, "onRemoteEventList Click() - Position=" + position + ", id=" + id);// Confirmation dialog based on: https://stackoverflow.com/a/12213536/2104584
|
||||
HashMap<String, String> eventObj = (HashMap<String, String>) adapter.getItemAtPosition(position);
|
||||
String eventId = eventObj.get("id");
|
||||
Log.d(TAG, "onItemClickListener(): eventId=" + eventId + ", eventObj=" + eventObj);
|
||||
Intent i = new Intent(getApplicationContext(), EditEventActivity.class);
|
||||
i.putExtra("eventId", eventId);
|
||||
startActivity(i);
|
||||
Log.v(TAG, "onItemClickListener() - Position=" + position + ", id=" + id);// Confirmation dialog based on: https://stackoverflow.com/a/12213536/2104584
|
||||
|
||||
if (mGroupEventsCb.isChecked() && mGroupedRemoteEventsList != null) {
|
||||
Log.v(TAG,"onItemClickListener() - Creating Grouped Events List from Position=" + position);
|
||||
// Get the group for this position
|
||||
ArrayList<HashMap<String, String>> group = mGroupedRemoteEventsList.get(position);
|
||||
ArrayList<String> eventIds = new ArrayList<>();
|
||||
for (HashMap<String, String> event : group) {
|
||||
Log.v(TAG,"onItemClickListener() - Adding event to edit list: " + event.get("id"));
|
||||
eventIds.add(event.get("id"));
|
||||
}
|
||||
Intent i = new Intent(getApplicationContext(), EditEventActivity.class);
|
||||
i.putStringArrayListExtra("eventIds", eventIds);
|
||||
startActivity(i);
|
||||
} else {
|
||||
Log.v(TAG,"onItemClickListener() - Editing Single event at Position=" + position);
|
||||
HashMap<String, String> eventObj = (HashMap<String, String>) adapter.getItemAtPosition(position);
|
||||
String eventId = eventObj.get("id");
|
||||
Intent i = new Intent(getApplicationContext(), EditEventActivity.class);
|
||||
i.putExtra("eventId", eventId);
|
||||
startActivity(i);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -776,6 +983,7 @@ public class LogManagerControlActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void showDataSharingDialog() {
|
||||
mUtil.writeToSysLogFile("MainActivity.showDataSharingDialog()");
|
||||
View aboutView = getLayoutInflater().inflate(R.layout.data_sharing_dialog_layout, null, false);
|
||||
|
||||
@@ -269,7 +269,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.i(TAG, "action_install_watch_app");
|
||||
mConnection.mSdServer.mSdDataSource.installWatchApp();
|
||||
return true;
|
||||
|
||||
case R.id.action_accept_alarm:
|
||||
Log.i(TAG, "action_accept_alarm");
|
||||
if (mConnection.mBound) {
|
||||
@@ -401,6 +400,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.i(TAG, "exception starting Report Seizure activity " + ex.toString());
|
||||
}
|
||||
return true;
|
||||
case R.id.action_sensor_validation:
|
||||
Log.i(TAG, "action_sensor_validation selected");
|
||||
mUtil.showToast("Opening Sensor Validation Test");
|
||||
|
||||
try {
|
||||
Intent intent = new Intent(
|
||||
MainActivity.this,
|
||||
SensorValidationActivity.class);
|
||||
this.startActivity(intent);
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "exception starting Sensor Validation activity", ex);
|
||||
mUtil.showToast("Error opening Sensor Validation: " + ex.toString());
|
||||
}
|
||||
return true;
|
||||
case R.id.action_settings:
|
||||
Log.i(TAG, "action_settings");
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,9 @@ package uk.org.openseizuredetector;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.MenuCompat;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter;
|
||||
@@ -18,11 +20,13 @@ import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.rohitss.uceh.UCEHandler;
|
||||
@@ -53,6 +57,22 @@ public class MainActivity2 extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main2);
|
||||
|
||||
// Handle system window insets for all API levels
|
||||
View rootView = findViewById(R.id.activity_main2_root_layout);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
|
||||
// Get the system bar insets
|
||||
int top = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top;
|
||||
int bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;
|
||||
|
||||
// Apply padding to your main content view
|
||||
LinearLayout content = findViewById(R.id.activity_main2_content_layout);
|
||||
content.setPadding(0, top, 0, bottom);
|
||||
|
||||
// Return the insets so they keep propagating
|
||||
return WindowInsetsCompat.CONSUMED;
|
||||
});
|
||||
|
||||
|
||||
Log.i(TAG, "onCreate()");
|
||||
|
||||
// Set our custom uncaught exception handler to report issues.
|
||||
@@ -270,6 +290,20 @@ public class MainActivity2 extends AppCompatActivity {
|
||||
Log.i(TAG, "exception starting log manager activity " + ex.toString());
|
||||
}
|
||||
return true;
|
||||
case R.id.action_sensor_validation:
|
||||
Log.i(TAG, "action_sensor_validation selected");
|
||||
mUtil.showToast("Opening Sensor Validation Test");
|
||||
|
||||
try {
|
||||
Intent intent = new Intent(
|
||||
MainActivity2.this,
|
||||
SensorValidationActivity.class);
|
||||
this.startActivity(intent);
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "exception starting Sensor Validation activity", ex);
|
||||
mUtil.showToast("Error opening Sensor Validation: " + ex.toString());
|
||||
}
|
||||
return true;
|
||||
case R.id.action_report_seizure:
|
||||
Log.i(TAG, "action_report_seizure");
|
||||
try {
|
||||
@@ -292,6 +326,34 @@ public class MainActivity2 extends AppCompatActivity {
|
||||
Log.i(TAG, "exception starting settings activity " + ex.toString());
|
||||
}
|
||||
return true;
|
||||
case R.id.action_instructions:
|
||||
Log.i(TAG, "action_instructions");
|
||||
try {
|
||||
String url = "https://www.openseizuredetector.org.uk/?page_id=1894";
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setData(Uri.parse(url));
|
||||
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(i);
|
||||
} catch (Exception ex) {
|
||||
Log.v(TAG, "exception displaying instructions " + ex.toString());
|
||||
mUtil.showToast("ERROR Displaying Instructions");
|
||||
}
|
||||
return true;
|
||||
|
||||
case R.id.action_troubleshooting:
|
||||
Log.i(TAG, "action_troubleshooting");
|
||||
try {
|
||||
String url = "https://www.openseizuredetector.org.uk/?page_id=2235";
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setData(Uri.parse(url));
|
||||
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(i);
|
||||
} catch (Exception ex) {
|
||||
Log.v(TAG, "exception displaying troubleshooting " + ex.toString());
|
||||
mUtil.showToast("ERROR Displaying Troubleshooting Tips");
|
||||
}
|
||||
return true;
|
||||
|
||||
case R.id.action_about:
|
||||
Log.i(TAG, "action_about");
|
||||
showAbout();
|
||||
|
||||
779
app/src/main/java/uk/org/openseizuredetector/ReportManager.java
Normal file
779
app/src/main/java/uk/org/openseizuredetector/ReportManager.java
Normal file
@@ -0,0 +1,779 @@
|
||||
package uk.org.openseizuredetector;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public class ReportManager {
|
||||
private static final String TAG = "ReportManager";
|
||||
private static final int GROUP_THRESHOLD_SECS = 30;
|
||||
private static final int MONTHS_TO_SHOW = 12;
|
||||
|
||||
public static String generateHtmlReport(SQLiteDatabase db, int days) {
|
||||
if (db == null) {
|
||||
return "<html><body><h1>Error: Database not available</h1></body></html>";
|
||||
}
|
||||
|
||||
// Fetch full calendar months for the report selector. This avoids the month dropdown
|
||||
// losing the previous month when the app moves into a new month.
|
||||
String query = "SELECT dataTime, status, type, notes, dataJSON " +
|
||||
"FROM events " +
|
||||
"WHERE dataTime >= date('now', 'start of month', '-" + (MONTHS_TO_SHOW - 1) + " months') " +
|
||||
"AND status IN (1, 2, 3, 5) " +
|
||||
"ORDER BY dataTime ASC";
|
||||
|
||||
Cursor cursor = null;
|
||||
ArrayList<SeizureEvent> rawEvents = new ArrayList<>();
|
||||
|
||||
try {
|
||||
cursor = db.rawQuery(query, null);
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.UK);
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
SeizureEvent event = new SeizureEvent();
|
||||
event.dataTime = cursor.getString(0);
|
||||
event.status = cursor.getInt(1);
|
||||
event.type = cursor.getString(2);
|
||||
event.notes = cursor.getString(3);
|
||||
|
||||
try {
|
||||
String dataJson = cursor.getString(4);
|
||||
if (dataJson != null) {
|
||||
JSONObject jo = new JSONObject(dataJson);
|
||||
event.hr = jo.optDouble("hr", 0.0);
|
||||
event.alarmPhrase = jo.optString("alarmPhrase", "");
|
||||
event.alarmCause = jo.optString("alarmCause", "").trim();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Error parsing dataJSON: " + e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
event.date = sdf.parse(event.dataTime);
|
||||
} catch (ParseException e) {
|
||||
Log.w(TAG, "Error parsing date: " + event.dataTime);
|
||||
}
|
||||
|
||||
rawEvents.add(event);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error querying database: " + e.getMessage());
|
||||
return "<html><body><h1>Error querying database: " + e.getMessage() + "</h1></body></html>";
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
ArrayList<SeizureGroup> groups = groupEvents(rawEvents);
|
||||
return buildCalendarHtml(groups, days);
|
||||
}
|
||||
|
||||
private static ArrayList<SeizureGroup> groupEvents(ArrayList<SeizureEvent> events) {
|
||||
ArrayList<SeizureGroup> groups = new ArrayList<>();
|
||||
if (events.isEmpty()) return groups;
|
||||
|
||||
SeizureGroup currentGroup = null;
|
||||
|
||||
for (SeizureEvent event : events) {
|
||||
if (currentGroup == null) {
|
||||
currentGroup = new SeizureGroup(event);
|
||||
} else {
|
||||
long diffSecs = 0;
|
||||
if (event.date != null && currentGroup.lastDate != null) {
|
||||
diffSecs = (event.date.getTime() - currentGroup.lastDate.getTime()) / 1000;
|
||||
}
|
||||
|
||||
if (diffSecs <= GROUP_THRESHOLD_SECS) {
|
||||
currentGroup.lastDate = event.date;
|
||||
currentGroup.lastEvent = event;
|
||||
if (event.notes != null && event.notes.contains("Duration:")) {
|
||||
currentGroup.durationStr = extractDuration(event.notes);
|
||||
}
|
||||
if (event.hr > 0) {
|
||||
currentGroup.hr = event.hr;
|
||||
}
|
||||
} else {
|
||||
groups.add(currentGroup);
|
||||
currentGroup = new SeizureGroup(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup != null) {
|
||||
groups.add(currentGroup);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static String extractDuration(String notes) {
|
||||
try {
|
||||
int start = notes.indexOf("Duration:") + 9;
|
||||
int end = notes.indexOf("HR:");
|
||||
if (end == -1) end = notes.length();
|
||||
return notes.substring(start, end).trim();
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static String getTimeOfDayColor(Date date) {
|
||||
if (date == null) return "#888888";
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTime(date);
|
||||
int hour = cal.get(Calendar.HOUR_OF_DAY);
|
||||
if (hour >= 0 && hour < 6) return "#3b4a6b"; // Night - dark blue
|
||||
if (hour >= 6 && hour < 12) return "#e6a817"; // Morning - yellow
|
||||
if (hour >= 12 && hour < 18) return "#e07b2a"; // Afternoon - orange
|
||||
return "#7b4fa6"; // Evening - purple
|
||||
}
|
||||
|
||||
private static String getTimeOfDayLabel(Date date) {
|
||||
if (date == null) return "";
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTime(date);
|
||||
int hour = cal.get(Calendar.HOUR_OF_DAY);
|
||||
if (hour >= 0 && hour < 6) return "Night";
|
||||
if (hour >= 6 && hour < 12) return "Morning";
|
||||
if (hour >= 12 && hour < 18) return "Afternoon";
|
||||
return "Evening";
|
||||
}
|
||||
|
||||
private static int getMinutesOfDay(Date date) {
|
||||
if (date == null) return 0;
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTime(date);
|
||||
return cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE);
|
||||
}
|
||||
|
||||
private static int parseDurationMinutes(String durationStr) {
|
||||
if (durationStr == null) return 5;
|
||||
|
||||
String s = durationStr.trim().toLowerCase(Locale.UK);
|
||||
if (s.isEmpty() || s.equals("n/a") || s.equals("unknown")) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
try {
|
||||
// Supports HH:MM:SS
|
||||
if (s.matches("\\d{1,2}:\\d{1,2}:\\d{1,2}")) {
|
||||
String[] parts = s.split(":");
|
||||
int hours = Integer.parseInt(parts[0]);
|
||||
int mins = Integer.parseInt(parts[1]);
|
||||
int secs = Integer.parseInt(parts[2]);
|
||||
int totalSeconds = hours * 3600 + mins * 60 + secs;
|
||||
return Math.max(1, (int) Math.ceil(totalSeconds / 60.0));
|
||||
}
|
||||
|
||||
// Supports MM:SS
|
||||
if (s.matches("\\d{1,2}:\\d{1,2}")) {
|
||||
String[] parts = s.split(":");
|
||||
int mins = Integer.parseInt(parts[0]);
|
||||
int secs = Integer.parseInt(parts[1]);
|
||||
int totalSeconds = mins * 60 + secs;
|
||||
return Math.max(1, (int) Math.ceil(totalSeconds / 60.0));
|
||||
}
|
||||
|
||||
// Supports text like "2 min 30 sec" or "1 hour 5 min"
|
||||
int totalMinutes = 0;
|
||||
String[] tokens = s.replace(",", " ").split("\\s+");
|
||||
for (int i = 0; i < tokens.length - 1; i++) {
|
||||
try {
|
||||
int value = Integer.parseInt(tokens[i]);
|
||||
String unit = tokens[i + 1];
|
||||
if (unit.startsWith("hour") || unit.startsWith("hr")) {
|
||||
totalMinutes += value * 60;
|
||||
} else if (unit.startsWith("min")) {
|
||||
totalMinutes += value;
|
||||
} else if (unit.startsWith("sec")) {
|
||||
if (value > 0) totalMinutes += 1;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Keep checking the rest of the duration text.
|
||||
}
|
||||
}
|
||||
|
||||
if (totalMinutes > 0) return totalMinutes;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
private static String getStatusColor(int status) {
|
||||
switch (status) {
|
||||
case 1: return "#f59e0b"; // WARNING
|
||||
case 2: return "#ef4444"; // ALARM
|
||||
case 3: return "#8b5cf6"; // FALL
|
||||
case 5: return "#06b6d4"; // MANUAL
|
||||
default: return "#6b7280";
|
||||
}
|
||||
}
|
||||
|
||||
private static String formatPct(double value) {
|
||||
return String.format(Locale.UK, "%.2f", value);
|
||||
}
|
||||
|
||||
private static String statusToString(int status) {
|
||||
switch (status) {
|
||||
case 1: return "WARNING";
|
||||
case 2: return "ALARM";
|
||||
case 3: return "FALL";
|
||||
case 5: return "MANUAL";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
private static String statusToCssClass(int status) {
|
||||
switch (status) {
|
||||
case 1: return "warning";
|
||||
case 2: return "alarm";
|
||||
case 3: return "fall";
|
||||
case 5: return "manual";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static String escapeHtml(String value) {
|
||||
if (value == null) return "";
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static void appendSummaryBar(StringBuilder sb, String label, String range, String cssClass,
|
||||
int count, int maxCount) {
|
||||
double pct = maxCount > 0 ? (count / (double) maxCount) * 100.0 : 0.0;
|
||||
sb.append("<div class='time-summary-row'>");
|
||||
sb.append("<div>").append(label).append("<br><span style='color:#6b7280;font-size:11px'>")
|
||||
.append(range).append("</span></div>");
|
||||
sb.append("<div class='summary-bar-track'><div class='summary-bar ").append(cssClass)
|
||||
.append("' style='width:").append(formatPct(pct)).append("%'></div></div>");
|
||||
sb.append("<div class='summary-count'>").append(count).append("</div>");
|
||||
sb.append("</div>");
|
||||
}
|
||||
|
||||
private static String buildCalendarHtml(ArrayList<SeizureGroup> groups, int days) {
|
||||
SimpleDateFormat dayKeyFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.UK);
|
||||
SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss", Locale.UK);
|
||||
SimpleDateFormat monthFormat = new SimpleDateFormat("MMMM yyyy", Locale.UK);
|
||||
SimpleDateFormat monthIdFormat = new SimpleDateFormat("yyyy-MM", Locale.UK);
|
||||
|
||||
// Group seizures by day and by month.
|
||||
Map<String, ArrayList<SeizureGroup>> byDay = new HashMap<>();
|
||||
Map<String, Integer> eventsByMonth = new HashMap<>();
|
||||
for (SeizureGroup g : groups) {
|
||||
if (g.startDate != null) {
|
||||
String dayKey = dayKeyFormat.format(g.startDate);
|
||||
if (!byDay.containsKey(dayKey)) {
|
||||
byDay.put(dayKey, new ArrayList<SeizureGroup>());
|
||||
}
|
||||
byDay.get(dayKey).add(g);
|
||||
|
||||
String monthKey = monthIdFormat.format(g.startDate);
|
||||
Integer count = eventsByMonth.get(monthKey);
|
||||
eventsByMonth.put(monthKey, count == null ? 1 : count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
Calendar todayCal = Calendar.getInstance();
|
||||
Date today = todayCal.getTime();
|
||||
|
||||
// Build a fixed list of recent full months so the dropdown remains useful across
|
||||
// month boundaries, for example allowing April to be selected after May begins.
|
||||
Calendar firstMonth = Calendar.getInstance();
|
||||
firstMonth.add(Calendar.MONTH, -(MONTHS_TO_SHOW - 1));
|
||||
firstMonth.set(Calendar.DAY_OF_MONTH, 1);
|
||||
firstMonth.set(Calendar.HOUR_OF_DAY, 0);
|
||||
firstMonth.set(Calendar.MINUTE, 0);
|
||||
firstMonth.set(Calendar.SECOND, 0);
|
||||
firstMonth.set(Calendar.MILLISECOND, 0);
|
||||
|
||||
Calendar lastMonth = Calendar.getInstance();
|
||||
lastMonth.set(Calendar.DAY_OF_MONTH, 1);
|
||||
lastMonth.set(Calendar.HOUR_OF_DAY, 0);
|
||||
lastMonth.set(Calendar.MINUTE, 0);
|
||||
lastMonth.set(Calendar.SECOND, 0);
|
||||
lastMonth.set(Calendar.MILLISECOND, 0);
|
||||
|
||||
ArrayList<Calendar> reportMonths = new ArrayList<>();
|
||||
Calendar monthCursor = (Calendar) firstMonth.clone();
|
||||
while (!monthCursor.after(lastMonth)) {
|
||||
reportMonths.add((Calendar) monthCursor.clone());
|
||||
monthCursor.add(Calendar.MONTH, 1);
|
||||
}
|
||||
|
||||
String defaultMonthId = monthIdFormat.format(today);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("<!DOCTYPE html><html><head>");
|
||||
sb.append("<meta charset='UTF-8'>");
|
||||
sb.append("<meta name='viewport' content='width=device-width, initial-scale=1'>");
|
||||
sb.append("<title>ClinX02 Seizure Report</title>");
|
||||
sb.append("<style>");
|
||||
sb.append("* { box-sizing: border-box; }");
|
||||
sb.append("body { font-family: Arial, sans-serif; margin: 0; padding: 16px; background: #eef1f5; color: #222; }");
|
||||
sb.append(".container { max-width: 1100px; margin: 0 auto; }");
|
||||
sb.append("h1 { color: #1f2937; margin: 0 0 6px 0; font-size: 28px; }");
|
||||
sb.append("h2 { color: #374151; margin-top: 24px; }");
|
||||
sb.append(".subtitle { color: #6b7280; margin-top: 0; margin-bottom: 18px; }");
|
||||
sb.append(".summary { background: white; border-radius: 12px; padding: 18px; margin-bottom: 18px; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }");
|
||||
sb.append(".stats { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }");
|
||||
sb.append(".stat-card { flex: 1 1 160px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; }");
|
||||
sb.append(".stat-label { font-size: 12px; color: #6b7280; }");
|
||||
sb.append(".stat-value { font-size: 22px; font-weight: bold; color: #111827; margin-top: 4px; }");
|
||||
sb.append(".download-btn { display: inline-block; margin-top: 14px; background: #2563eb; color: white; text-decoration: none; padding: 10px 14px; border-radius: 8px; font-weight: bold; }");
|
||||
sb.append(".download-btn:hover { background: #1d4ed8; }");
|
||||
sb.append(".legend { display: flex; gap: 16px; margin: 12px 0 18px 0; flex-wrap: wrap; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }");
|
||||
sb.append(".legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #374151; }");
|
||||
sb.append(".legend-dot { width: 12px; height: 12px; border-radius: 50%; flex: 0 0 auto; }");
|
||||
sb.append(".month-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 16px 0; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }");
|
||||
sb.append(".month-controls label { font-weight: bold; color: #374151; }");
|
||||
sb.append(".month-controls select { padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; background: white; font-size: 14px; min-width: 190px; }");
|
||||
sb.append(".month-count { color: #6b7280; font-size: 13px; }");
|
||||
sb.append(".month-panel { display: none; }");
|
||||
sb.append(".month-panel.active { display: block; }");
|
||||
sb.append(".month-summary-panel { display: none; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 14px; margin: 14px 0 18px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }");
|
||||
sb.append(".month-summary-panel.active { display: block; }");
|
||||
sb.append(".summary-chart-title { font-weight: bold; color: #374151; margin-bottom: 10px; }");
|
||||
sb.append(".time-summary-row { display: grid; grid-template-columns: 90px 1fr 44px; gap: 10px; align-items: center; margin: 8px 0; font-size: 13px; }");
|
||||
sb.append(".summary-bar-track { height: 18px; background: #f3f4f6; border-radius: 999px; overflow: hidden; border: 1px solid #e5e7eb; }");
|
||||
sb.append(".summary-bar { height: 100%; min-width: 0; border-radius: 999px; }");
|
||||
sb.append(".summary-bar.night { background: #9ca3af; }");
|
||||
sb.append(".summary-bar.morning { background: #f59e0b; }");
|
||||
sb.append(".summary-bar.afternoon { background: #f97316; }");
|
||||
sb.append(".summary-bar.evening { background: #8b5cf6; }");
|
||||
sb.append(".summary-count { text-align: right; color: #374151; font-weight: bold; }");
|
||||
sb.append(".table-controls { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 12px; margin-bottom: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }");
|
||||
sb.append(".table-controls label { font-weight: bold; color: #374151; font-size: 13px; }");
|
||||
sb.append(".table-controls select { padding: 7px 9px; border: 1px solid #d1d5db; border-radius: 8px; background: white; font-size: 13px; }");
|
||||
sb.append(".pager { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }");
|
||||
sb.append(".pager button { padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 8px; background: #f9fafb; cursor: pointer; }");
|
||||
sb.append(".pager button:disabled { opacity: 0.45; cursor: not-allowed; }");
|
||||
sb.append(".table-info { color: #6b7280; font-size: 13px; }");
|
||||
sb.append(".calendar { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-top: 16px; }");
|
||||
sb.append(".day-header { text-align: center; font-weight: bold; padding: 6px; color: #555; font-size: 13px; }");
|
||||
sb.append(".day-box { background: white; border-radius: 8px; padding: 6px; min-height: 86px; border: 1px solid #ddd; box-shadow: 0 1px 4px rgba(0,0,0,0.04); }");
|
||||
sb.append(".day-box.today { border: 2px solid #2563eb; }");
|
||||
sb.append(".day-box.has-seizures { background: #fff8f8; border-color: #fecaca; }");
|
||||
sb.append(".day-box.empty { background: transparent; border: none; box-shadow: none; }");
|
||||
sb.append(".day-num { font-size: 13px; font-weight: bold; color: #333; margin-bottom: 4px; }");
|
||||
sb.append(".timeline { position: relative; height: 28px; margin-top: 6px; border-radius: 6px; overflow: hidden; border: 1px solid #d1d5db; background: white; isolation: isolate; }");
|
||||
sb.append(".time-bg { position: absolute; inset: 0; display: flex; z-index: 0; }");
|
||||
sb.append(".time-segment { height: 100%; flex: 1 1 25%; }");
|
||||
sb.append(".time-night { background: #e5e7eb; }");
|
||||
sb.append(".time-morning { background: #fde68a; }");
|
||||
sb.append(".time-afternoon { background: #fdba74; }");
|
||||
sb.append(".time-evening { background: #c4b5fd; }");
|
||||
sb.append(".timeline-grid { position: absolute; inset: 0; pointer-events: none; z-index: 1; }");
|
||||
sb.append(".time-marker { position: absolute; top: 0; bottom: 0; width: 1px; background: rgba(0,0,0,0.16); }");
|
||||
sb.append(".event-bar { position: absolute; top: 5px; height: 18px; border-radius: 4px; opacity: 0.96; border: 1px solid rgba(0,0,0,0.18); z-index: 2; }");
|
||||
sb.append(".time-scale { display: flex; justify-content: space-between; font-size: 9px; color: #6b7280; margin-top: 3px; }");
|
||||
sb.append(".seizure-count { font-size: 11px; color: #6b7280; margin-top: 4px; }");
|
||||
sb.append(".table-wrap { width: 100%; overflow-x: auto; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }");
|
||||
sb.append(".detail-table { width: 100%; border-collapse: collapse; background: white; overflow: hidden; min-width: 680px; }");
|
||||
sb.append(".detail-table th { background: #2563eb; color: white; padding: 12px; text-align: left; font-size: 13px; }");
|
||||
sb.append(".detail-table td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }");
|
||||
sb.append(".detail-table tr:hover { background: #f9fafb; }");
|
||||
sb.append(".detail-table tr:last-child td { border-bottom: none; }");
|
||||
sb.append(".status-pill { display: inline-block; padding: 4px 8px; border-radius: 999px; font-size: 12px; font-weight: bold; }");
|
||||
sb.append(".alarm { background: #fee2e2; color: #b91c1c; }");
|
||||
sb.append(".warning { background: #ffedd5; color: #c2410c; }");
|
||||
sb.append(".fall { background: #ede9fe; color: #6d28d9; }");
|
||||
sb.append(".manual { background: #dcfce7; color: #15803d; }");
|
||||
sb.append(".unknown { background: #e5e7eb; color: #374151; }");
|
||||
sb.append(".no-month-events { display: none; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 14px; color: #6b7280; }");
|
||||
sb.append(".footer { color: #6b7280; margin-top: 18px; }");
|
||||
sb.append("@media (max-width: 700px) {");
|
||||
sb.append("body { padding: 10px; }");
|
||||
sb.append("h1 { font-size: 23px; }");
|
||||
sb.append(".summary { padding: 14px; }");
|
||||
sb.append(".time-summary-row { grid-template-columns: 78px 1fr 34px; gap: 6px; font-size: 11px; }");
|
||||
sb.append(".table-controls { align-items: stretch; }");
|
||||
sb.append(".pager { width: 100%; }");
|
||||
sb.append(".calendar { gap: 2px; }");
|
||||
sb.append(".day-box { min-height: 64px; padding: 4px; border-radius: 6px; }");
|
||||
sb.append(".day-header { font-size: 11px; padding: 4px; }");
|
||||
sb.append(".day-num { font-size: 11px; }");
|
||||
sb.append(".timeline { height: 22px; }");
|
||||
sb.append(".event-bar { top: 4px; height: 14px; }");
|
||||
sb.append(".time-scale { font-size: 8px; }");
|
||||
sb.append(".seizure-count { font-size: 10px; }");
|
||||
sb.append(".detail-table th, .detail-table td { font-size: 11px; padding: 6px; }");
|
||||
sb.append("}");
|
||||
sb.append("</style></head><body>");
|
||||
sb.append("<div class='container'>");
|
||||
|
||||
sb.append("<h1>ClinX02 Seizure Report</h1>");
|
||||
sb.append("<p class='subtitle'>Calendar timeline overview and detailed event log</p>");
|
||||
|
||||
// Summary box
|
||||
sb.append("<div class='summary'>");
|
||||
sb.append("<div class='stats'>");
|
||||
|
||||
sb.append("<div class='stat-card'>");
|
||||
sb.append("<div class='stat-label'>Calendar range</div>");
|
||||
sb.append("<div class='stat-value'>").append(MONTHS_TO_SHOW).append(" months</div>");
|
||||
sb.append("</div>");
|
||||
|
||||
sb.append("<div class='stat-card'>");
|
||||
sb.append("<div class='stat-label'>Total seizure events</div>");
|
||||
sb.append("<div class='stat-value'>").append(groups.size()).append("</div>");
|
||||
sb.append("</div>");
|
||||
|
||||
sb.append("<div class='stat-card'>");
|
||||
sb.append("<div class='stat-label'>Days with seizures</div>");
|
||||
sb.append("<div class='stat-value'>").append(byDay.size()).append("</div>");
|
||||
sb.append("</div>");
|
||||
|
||||
sb.append("</div>");
|
||||
sb.append("<a class='download-btn' href='/report/download?days=").append(days)
|
||||
.append("&token=clinx02secure' download='seizure_report.html'>Download Report</a>");
|
||||
sb.append("</div>");
|
||||
|
||||
// Legend
|
||||
sb.append("<div class='legend'>");
|
||||
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#e5e7eb'></div>Night background (00-06)</div>");
|
||||
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#fde68a'></div>Morning background (06-12)</div>");
|
||||
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#fdba74'></div>Afternoon background (12-18)</div>");
|
||||
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#c4b5fd'></div>Evening background (18-24)</div>");
|
||||
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#f59e0b'></div>Warning bar</div>");
|
||||
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#ef4444'></div>Alarm bar</div>");
|
||||
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#8b5cf6'></div>Fall bar</div>");
|
||||
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#06b6d4'></div>Manual bar</div>");
|
||||
sb.append("</div>");
|
||||
|
||||
// Month selector
|
||||
sb.append("<div class='month-controls'>");
|
||||
sb.append("<label for='monthSelect'>Month:</label>");
|
||||
sb.append("<select id='monthSelect' onchange='showMonth(this.value)'>");
|
||||
for (Calendar reportMonth : reportMonths) {
|
||||
String monthId = monthIdFormat.format(reportMonth.getTime());
|
||||
String selected = monthId.equals(defaultMonthId) ? " selected" : "";
|
||||
Integer eventCount = eventsByMonth.get(monthId);
|
||||
int count = eventCount == null ? 0 : eventCount;
|
||||
sb.append("<option value='").append(monthId).append("'").append(selected).append(">");
|
||||
sb.append(monthFormat.format(reportMonth.getTime())).append(" (").append(count)
|
||||
.append(count == 1 ? " event" : " events").append(")");
|
||||
sb.append("</option>");
|
||||
}
|
||||
sb.append("</select>");
|
||||
sb.append("<span id='selectedMonthCount' class='month-count'></span>");
|
||||
sb.append("</div>");
|
||||
|
||||
// Monthly time-of-day summary chart
|
||||
for (Calendar reportMonth : reportMonths) {
|
||||
String monthId = monthIdFormat.format(reportMonth.getTime());
|
||||
boolean activeMonth = monthId.equals(defaultMonthId);
|
||||
int nightCount = 0;
|
||||
int morningCount = 0;
|
||||
int afternoonCount = 0;
|
||||
int eveningCount = 0;
|
||||
|
||||
for (SeizureGroup g : groups) {
|
||||
if (g.startDate != null && monthId.equals(monthIdFormat.format(g.startDate))) {
|
||||
Calendar eventCal = Calendar.getInstance();
|
||||
eventCal.setTime(g.startDate);
|
||||
int hour = eventCal.get(Calendar.HOUR_OF_DAY);
|
||||
if (hour < 6) {
|
||||
nightCount++;
|
||||
} else if (hour < 12) {
|
||||
morningCount++;
|
||||
} else if (hour < 18) {
|
||||
afternoonCount++;
|
||||
} else {
|
||||
eveningCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int maxTimeCount = Math.max(Math.max(nightCount, morningCount), Math.max(afternoonCount, eveningCount));
|
||||
if (maxTimeCount < 1) maxTimeCount = 1;
|
||||
|
||||
sb.append("<div class='month-summary-panel");
|
||||
if (activeMonth) sb.append(" active");
|
||||
sb.append("' id='summary-").append(monthId).append("' data-month='").append(monthId).append("'>");
|
||||
sb.append("<div class='summary-chart-title'>Seizures by time of day</div>");
|
||||
appendSummaryBar(sb, "Night", "00-06", "night", nightCount, maxTimeCount);
|
||||
appendSummaryBar(sb, "Morning", "06-12", "morning", morningCount, maxTimeCount);
|
||||
appendSummaryBar(sb, "Afternoon", "12-18", "afternoon", afternoonCount, maxTimeCount);
|
||||
appendSummaryBar(sb, "Evening", "18-24", "evening", eveningCount, maxTimeCount);
|
||||
sb.append("</div>");
|
||||
}
|
||||
|
||||
// Calendar months
|
||||
for (Calendar reportMonth : reportMonths) {
|
||||
String monthId = monthIdFormat.format(reportMonth.getTime());
|
||||
boolean activeMonth = monthId.equals(defaultMonthId);
|
||||
|
||||
Calendar cal = (Calendar) reportMonth.clone();
|
||||
int currentMonth = cal.get(Calendar.MONTH);
|
||||
int currentYear = cal.get(Calendar.YEAR);
|
||||
int daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
|
||||
int firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK); // 1=Sun, 2=Mon...
|
||||
|
||||
sb.append("<div class='month-panel");
|
||||
if (activeMonth) sb.append(" active");
|
||||
sb.append("' id='month-").append(monthId).append("' data-month='").append(monthId).append("'>");
|
||||
sb.append("<h2>").append(monthFormat.format(reportMonth.getTime())).append("</h2>");
|
||||
sb.append("<div class='calendar'>");
|
||||
|
||||
String[] dayNames = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
|
||||
for (String d : dayNames) {
|
||||
sb.append("<div class='day-header'>").append(d).append("</div>");
|
||||
}
|
||||
|
||||
for (int i = 1; i < firstDayOfWeek; i++) {
|
||||
sb.append("<div class='day-box empty'></div>");
|
||||
}
|
||||
|
||||
Calendar today2 = Calendar.getInstance();
|
||||
for (int day = 1; day <= daysInMonth; day++) {
|
||||
String dayKey = String.format(Locale.UK, "%04d-%02d-%02d", currentYear, currentMonth + 1, day);
|
||||
ArrayList<SeizureGroup> dayGroups = byDay.get(dayKey);
|
||||
boolean isToday = (day == today2.get(Calendar.DAY_OF_MONTH)
|
||||
&& currentMonth == today2.get(Calendar.MONTH)
|
||||
&& currentYear == today2.get(Calendar.YEAR));
|
||||
|
||||
String boxClass = "day-box";
|
||||
if (isToday) boxClass += " today";
|
||||
if (dayGroups != null && !dayGroups.isEmpty()) boxClass += " has-seizures";
|
||||
|
||||
sb.append("<div class='").append(boxClass).append("'>");
|
||||
sb.append("<div class='day-num'>").append(day).append("</div>");
|
||||
|
||||
if (dayGroups != null && !dayGroups.isEmpty()) {
|
||||
sb.append("<div class='timeline'>");
|
||||
sb.append("<div class='time-bg'>");
|
||||
sb.append("<div class='time-segment time-night'></div>");
|
||||
sb.append("<div class='time-segment time-morning'></div>");
|
||||
sb.append("<div class='time-segment time-afternoon'></div>");
|
||||
sb.append("<div class='time-segment time-evening'></div>");
|
||||
sb.append("</div>");
|
||||
sb.append("<div class='timeline-grid'>");
|
||||
sb.append("<span class='time-marker' style='left:25%'></span>");
|
||||
sb.append("<span class='time-marker' style='left:50%'></span>");
|
||||
sb.append("<span class='time-marker' style='left:75%'></span>");
|
||||
sb.append("</div>");
|
||||
|
||||
for (SeizureGroup g : dayGroups) {
|
||||
int startMinutes = getMinutesOfDay(g.startDate);
|
||||
int durationMinutes = parseDurationMinutes(g.durationStr);
|
||||
|
||||
double leftPct = (startMinutes / 1440.0) * 100.0;
|
||||
double widthPct = Math.max((durationMinutes / 1440.0) * 100.0, 1.4);
|
||||
|
||||
if (leftPct + widthPct > 100.0) {
|
||||
widthPct = Math.max(0.8, 100.0 - leftPct);
|
||||
}
|
||||
|
||||
String timeStr = g.startDate != null ? timeFormat.format(g.startDate) : "";
|
||||
String label = getTimeOfDayLabel(g.startDate);
|
||||
String tip = label + " " + timeStr + " - " + statusToString(g.firstEvent.status)
|
||||
+ " (" + g.durationStr + ")";
|
||||
|
||||
sb.append("<div class='event-bar' style='left:")
|
||||
.append(formatPct(leftPct))
|
||||
.append("%;width:")
|
||||
.append(formatPct(widthPct))
|
||||
.append("%;background:")
|
||||
.append(getStatusColor(g.firstEvent.status))
|
||||
.append(";' title='")
|
||||
.append(escapeHtml(tip))
|
||||
.append("'></div>");
|
||||
}
|
||||
|
||||
sb.append("</div>");
|
||||
sb.append("<div class='time-scale'><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>");
|
||||
sb.append("<div class='seizure-count'>").append(dayGroups.size())
|
||||
.append(dayGroups.size() == 1 ? " event" : " events").append("</div>");
|
||||
}
|
||||
|
||||
sb.append("</div>");
|
||||
}
|
||||
|
||||
sb.append("</div>"); // calendar
|
||||
sb.append("</div>"); // month-panel
|
||||
}
|
||||
|
||||
// Detailed table below. JavaScript filters this table to the selected month.
|
||||
sb.append("<h2>Event Details</h2>");
|
||||
if (groups.isEmpty()) {
|
||||
sb.append("<p>No seizure events recorded in this period.</p>");
|
||||
} else {
|
||||
sb.append("<p id='emptyMonthMessage' class='no-month-events'>No seizure events recorded for the selected month.</p>");
|
||||
sb.append("<div class='table-controls' id='eventTableControls'>");
|
||||
sb.append("<div><label for='rowsPerPage'>Entries per page: </label>");
|
||||
sb.append("<select id='rowsPerPage' onchange='changePageSize()'>");
|
||||
sb.append("<option value='5'>5</option>");
|
||||
sb.append("<option value='10' selected>10</option>");
|
||||
sb.append("<option value='25'>25</option>");
|
||||
sb.append("<option value='50'>50</option>");
|
||||
sb.append("<option value='all'>All</option>");
|
||||
sb.append("</select></div>");
|
||||
sb.append("<div class='pager'>");
|
||||
sb.append("<button type='button' id='prevPageBtn' onclick='previousPage()'>Previous</button>");
|
||||
sb.append("<label for='pageSelect'>Page: </label>");
|
||||
sb.append("<select id='pageSelect' onchange='changePage()'></select>");
|
||||
sb.append("<button type='button' id='nextPageBtn' onclick='nextPage()'>Next</button>");
|
||||
sb.append("</div>");
|
||||
sb.append("<div id='tableInfo' class='table-info'></div>");
|
||||
sb.append("</div>");
|
||||
sb.append("<div class='table-wrap' id='eventTableWrap'><table class='detail-table'>");
|
||||
sb.append("<tr><th>#</th><th>Date & Time</th><th>Status</th><th>Duration</th><th>Heart Rate</th><th>Cause</th></tr>");
|
||||
SimpleDateFormat displaySdf = new SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.UK);
|
||||
int i = 1;
|
||||
for (SeizureGroup g : groups) {
|
||||
String timeStr = g.startDate != null ? displaySdf.format(g.startDate) : g.firstEvent.dataTime;
|
||||
String statusStr = statusToString(g.firstEvent.status);
|
||||
String cssClass = statusToCssClass(g.firstEvent.status);
|
||||
String hrStr = g.hr > 0 ? String.format(Locale.UK, "%.1f bpm", g.hr) : "N/A";
|
||||
String cause = g.firstEvent.alarmCause != null && !g.firstEvent.alarmCause.isEmpty()
|
||||
? g.firstEvent.alarmCause : "Unknown";
|
||||
String dotColor = getTimeOfDayColor(g.startDate);
|
||||
String eventMonth = g.startDate != null ? monthIdFormat.format(g.startDate) : "";
|
||||
|
||||
sb.append("<tr class='event-row' data-month='").append(eventMonth).append("'>");
|
||||
sb.append("<td>").append(i++).append("</td>");
|
||||
sb.append("<td><span style='display:inline-block;width:10px;height:10px;border-radius:50%;background:")
|
||||
.append(dotColor).append(";margin-right:6px'></span>").append(timeStr).append("</td>");
|
||||
sb.append("<td><span class='status-pill ").append(cssClass).append("'>").append(statusStr).append("</span></td>");
|
||||
sb.append("<td>").append(escapeHtml(g.durationStr)).append("</td>");
|
||||
sb.append("<td>").append(hrStr).append("</td>");
|
||||
sb.append("<td>").append(escapeHtml(cause)).append("</td>");
|
||||
sb.append("</tr>");
|
||||
}
|
||||
sb.append("</table></div>");
|
||||
}
|
||||
|
||||
sb.append("<p class='footer'><small>Generated by ClinX02 OpenSeizureDetector</small></p>");
|
||||
sb.append("</div>");
|
||||
|
||||
sb.append("<script>");
|
||||
sb.append("var currentPage = 1;");
|
||||
sb.append("function getSelectedMonth() {");
|
||||
sb.append("var select = document.getElementById('monthSelect');");
|
||||
sb.append("return select ? select.value : ''; }");
|
||||
sb.append("function showMonth(monthId) {");
|
||||
sb.append("var panels = document.querySelectorAll('.month-panel');");
|
||||
sb.append("for (var i = 0; i < panels.length; i++) { panels[i].classList.remove('active'); }");
|
||||
sb.append("var selectedPanel = document.getElementById('month-' + monthId);");
|
||||
sb.append("if (selectedPanel) { selectedPanel.classList.add('active'); }");
|
||||
sb.append("var summaries = document.querySelectorAll('.month-summary-panel');");
|
||||
sb.append("for (var s = 0; s < summaries.length; s++) { summaries[s].classList.remove('active'); }");
|
||||
sb.append("var selectedSummary = document.getElementById('summary-' + monthId);");
|
||||
sb.append("if (selectedSummary) { selectedSummary.classList.add('active'); }");
|
||||
sb.append("currentPage = 1;");
|
||||
sb.append("updateTable();");
|
||||
sb.append("}");
|
||||
sb.append("function changePageSize() { currentPage = 1; updateTable(); }");
|
||||
sb.append("function changePage() {");
|
||||
sb.append("var pageSelect = document.getElementById('pageSelect');");
|
||||
sb.append("if (pageSelect) { currentPage = parseInt(pageSelect.value, 10) || 1; }");
|
||||
sb.append("updateTable();");
|
||||
sb.append("}");
|
||||
sb.append("function previousPage() { if (currentPage > 1) { currentPage--; updateTable(); } }");
|
||||
sb.append("function nextPage() {");
|
||||
sb.append("var pageSelect = document.getElementById('pageSelect');");
|
||||
sb.append("var totalPages = pageSelect ? pageSelect.options.length : 1;");
|
||||
sb.append("if (currentPage < totalPages) { currentPage++; updateTable(); }");
|
||||
sb.append("}");
|
||||
sb.append("function updateTable() {");
|
||||
sb.append("var monthId = getSelectedMonth();");
|
||||
sb.append("var rows = document.querySelectorAll('.event-row');");
|
||||
sb.append("var matchingRows = [];");
|
||||
sb.append("for (var r = 0; r < rows.length; r++) {");
|
||||
sb.append("var matches = rows[r].getAttribute('data-month') === monthId;");
|
||||
sb.append("rows[r].style.display = 'none';");
|
||||
sb.append("if (matches) { matchingRows.push(rows[r]); }");
|
||||
sb.append("}");
|
||||
sb.append("var rowsPerPageSelect = document.getElementById('rowsPerPage');");
|
||||
sb.append("var rowsPerPageValue = rowsPerPageSelect ? rowsPerPageSelect.value : '10';");
|
||||
sb.append("var rowsPerPage = rowsPerPageValue === 'all' ? Math.max(matchingRows.length, 1) : parseInt(rowsPerPageValue, 10);");
|
||||
sb.append("if (!rowsPerPage || rowsPerPage < 1) { rowsPerPage = 10; }");
|
||||
sb.append("var totalPages = Math.max(1, Math.ceil(matchingRows.length / rowsPerPage));");
|
||||
sb.append("if (currentPage > totalPages) { currentPage = totalPages; }");
|
||||
sb.append("if (currentPage < 1) { currentPage = 1; }");
|
||||
sb.append("var startIndex = (currentPage - 1) * rowsPerPage;");
|
||||
sb.append("var endIndex = Math.min(startIndex + rowsPerPage, matchingRows.length);");
|
||||
sb.append("for (var i = startIndex; i < endIndex; i++) { matchingRows[i].style.display = ''; }");
|
||||
sb.append("var pageSelect = document.getElementById('pageSelect');");
|
||||
sb.append("if (pageSelect) {");
|
||||
sb.append("pageSelect.innerHTML = '';");
|
||||
sb.append("for (var p = 1; p <= totalPages; p++) {");
|
||||
sb.append("var option = document.createElement('option'); option.value = p; option.textContent = p + ' of ' + totalPages;");
|
||||
sb.append("if (p === currentPage) { option.selected = true; }");
|
||||
sb.append("pageSelect.appendChild(option); }");
|
||||
sb.append("}");
|
||||
sb.append("var wrap = document.getElementById('eventTableWrap');");
|
||||
sb.append("var empty = document.getElementById('emptyMonthMessage');");
|
||||
sb.append("var controls = document.getElementById('eventTableControls');");
|
||||
sb.append("var hasRows = matchingRows.length > 0;");
|
||||
sb.append("if (wrap) { wrap.style.display = hasRows ? '' : 'none'; }");
|
||||
sb.append("if (controls) { controls.style.display = hasRows ? '' : 'none'; }");
|
||||
sb.append("if (empty) { empty.style.display = hasRows ? 'none' : 'block'; }");
|
||||
sb.append("var countText = document.getElementById('selectedMonthCount');");
|
||||
sb.append("if (countText) { countText.textContent = matchingRows.length + (matchingRows.length === 1 ? ' event in selected month' : ' events in selected month'); }");
|
||||
sb.append("var info = document.getElementById('tableInfo');");
|
||||
sb.append("if (info) {");
|
||||
sb.append("if (hasRows) { info.textContent = 'Showing ' + (startIndex + 1) + '-' + endIndex + ' of ' + matchingRows.length; }");
|
||||
sb.append("else { info.textContent = ''; }");
|
||||
sb.append("}");
|
||||
sb.append("var prev = document.getElementById('prevPageBtn');");
|
||||
sb.append("var next = document.getElementById('nextPageBtn');");
|
||||
sb.append("if (prev) { prev.disabled = currentPage <= 1; }");
|
||||
sb.append("if (next) { next.disabled = currentPage >= totalPages; }");
|
||||
sb.append("}");
|
||||
sb.append("document.addEventListener('DOMContentLoaded', function() {");
|
||||
sb.append("var select = document.getElementById('monthSelect');");
|
||||
sb.append("if (select) { showMonth(select.value); }");
|
||||
sb.append("});");
|
||||
sb.append("</script>");
|
||||
sb.append("</body></html>");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static class SeizureEvent {
|
||||
String dataTime;
|
||||
int status;
|
||||
String type;
|
||||
String notes;
|
||||
String alarmPhrase;
|
||||
String alarmCause;
|
||||
double hr = 0.0;
|
||||
Date date;
|
||||
}
|
||||
|
||||
static class SeizureGroup {
|
||||
SeizureEvent firstEvent;
|
||||
SeizureEvent lastEvent;
|
||||
Date startDate;
|
||||
Date lastDate;
|
||||
String durationStr = "N/A";
|
||||
double hr = 0.0;
|
||||
|
||||
SeizureGroup(SeizureEvent first) {
|
||||
this.firstEvent = first;
|
||||
this.lastEvent = first;
|
||||
this.startDate = first.date;
|
||||
this.lastDate = first.date;
|
||||
if (first.notes != null && first.notes.contains("Duration:")) {
|
||||
this.durationStr = extractDuration(first.notes);
|
||||
}
|
||||
this.hr = first.hr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,14 @@ import android.os.Handler;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.DatePicker;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.TextView;
|
||||
@@ -78,6 +81,21 @@ public class ReportSeizureActivity extends AppCompatActivity {
|
||||
mConnection = new SdServiceConnection(getApplicationContext());
|
||||
|
||||
setContentView(R.layout.activity_report_seizure);
|
||||
// Handle system window insets for all API levels
|
||||
View rootView = findViewById(R.id.root_layout_report_seizure);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
|
||||
// Get the system bar insets
|
||||
int top = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top;
|
||||
int bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;
|
||||
|
||||
// Apply padding to your main content view
|
||||
LinearLayout content = findViewById(R.id.report_seizure_content_layout);
|
||||
content.setPadding(0, top, 0, bottom);
|
||||
|
||||
// Return the insets so they keep propagating
|
||||
return WindowInsetsCompat.CONSUMED;
|
||||
});
|
||||
|
||||
|
||||
mEventTypeRg = findViewById(R.id.eventTypeRg);
|
||||
mEventTypeRg.setOnCheckedChangeListener(onEventTypeChange);
|
||||
|
||||
@@ -125,6 +125,8 @@ public class SdServer extends Service implements SdDataReceiver {
|
||||
private boolean mMp3Alarm = false;
|
||||
private boolean mPhoneAlarm = false;
|
||||
private boolean mSMSAlarm = false;
|
||||
private boolean mFlogaEmergencyEscalation = true;
|
||||
private long mAlarmStartTime = 0;
|
||||
private String[] mSMSNumbers;
|
||||
private String mSMSMsgStr = "default SMS Message";
|
||||
private String mSMSFalseAlarmMsgStr = "default SMS False Alarm Message";
|
||||
@@ -655,6 +657,7 @@ public class SdServer extends Service implements SdDataReceiver {
|
||||
if ((sdData.alarmState == 2) || (sdData.alarmState == 5)) {
|
||||
sdData.alarmPhrase = "ALARM";
|
||||
sdData.alarmStanding = true;
|
||||
if (mAlarmStartTime == 0) mAlarmStartTime = System.currentTimeMillis();
|
||||
if (mLogAlarms) {
|
||||
Log.v(TAG, "***ALARM*** - Logging to SD Card");
|
||||
//writeAlarmToSD();
|
||||
@@ -675,7 +678,12 @@ public class SdServer extends Service implements SdDataReceiver {
|
||||
- mSMSTime.toMillis(false))
|
||||
> 60000) {
|
||||
sendSMSAlarm();
|
||||
sendPhoneAlarm();
|
||||
// Floga: escalate to emergency call if seizure longer than 5 minutes
|
||||
long alarmDurationSecs = (System.currentTimeMillis() - mAlarmStartTime) / 1000;
|
||||
if (alarmDurationSecs >= 300 && mFlogaEmergencyEscalation) {
|
||||
sendPhoneAlarm();
|
||||
Log.v(TAG, "Floga: 5 minute threshold reached - calling emergency contacts");
|
||||
}
|
||||
mSMSTime = tnow;
|
||||
} else {
|
||||
mUtil.showToast(getString(R.string.SMSAlarmAlreadySentMsg));
|
||||
@@ -820,6 +828,7 @@ public class SdServer extends Service implements SdDataReceiver {
|
||||
mSdData.alarmState = 4; // set fault alarm state.
|
||||
mSdData.alarmPhrase = "FAULT";
|
||||
mSdData.alarmStanding = false;
|
||||
mAlarmStartTime = 0;
|
||||
if (webServer != null) webServer.setSdData(mSdData);
|
||||
// We only take action to warn the user and re-start the data source to attempt to fix it
|
||||
// ourselves if we have been in a fault condition for a while - signified by the mFaultTimerCompleted
|
||||
@@ -1238,7 +1247,11 @@ public class SdServer extends Service implements SdDataReceiver {
|
||||
if (mLm != null) {
|
||||
Log.v(TAG, "logData() - writing data to Database");
|
||||
//writeToSD();
|
||||
mLm.writeDatapointToLocalDb(mSdData);
|
||||
long alarmDurationSecs = 0;
|
||||
if (mAlarmStartTime != 0) {
|
||||
alarmDurationSecs = (System.currentTimeMillis() - mAlarmStartTime) / 1000;
|
||||
}
|
||||
mLm.writeDatapointToLocalDb(mSdData, alarmDurationSecs);
|
||||
} else {
|
||||
Log.e(TAG, "logData() - mLm is null - this should not happen");
|
||||
}
|
||||
@@ -1302,6 +1315,7 @@ public class SdServer extends Service implements SdDataReceiver {
|
||||
mSMSAlarm = SP.getBoolean("SMSAlarm", false);
|
||||
Log.v(TAG, "updatePrefs() - mSMSAlarm = " + mSMSAlarm);
|
||||
mUtil.writeToSysLogFile("updatePrefs() - mSMSAlarm = " + mSMSAlarm);
|
||||
mFlogaEmergencyEscalation = SP.getBoolean("FlogaEmergencyEscalation", true);
|
||||
mPhoneAlarm = SP.getBoolean("PhoneCallAlarm", false);
|
||||
Log.v(TAG, "updatePrefs() - mSMSAlarm = " + mSMSAlarm);
|
||||
mUtil.writeToSysLogFile("updatePrefs() - mSMSAlarm = " + mSMSAlarm);
|
||||
|
||||
@@ -26,12 +26,18 @@ import fi.iki.elonen.NanoHTTPD;
|
||||
*/
|
||||
public class SdWebServer extends NanoHTTPD {
|
||||
private String TAG = "WebServer";
|
||||
private static final String REPORT_TOKEN = "clinx02secure";
|
||||
private SdData mSdData;
|
||||
private SdServer mSdServer;
|
||||
private Context mContext;
|
||||
private Handler mHandler;
|
||||
private OsdUtil mUtil;
|
||||
|
||||
private boolean isValidToken(Map<String, String> parameters) {
|
||||
String token = parameters.get("token");
|
||||
return REPORT_TOKEN.equals(token);
|
||||
}
|
||||
|
||||
public SdWebServer(Context context, SdData sdData, SdServer sdServer) {
|
||||
// Set the port to listen on (8080)
|
||||
super(8080);
|
||||
@@ -192,6 +198,33 @@ public class SdWebServer extends NanoHTTPD {
|
||||
answer = "{'msg' : 'Alarm Accepted'}";
|
||||
break;
|
||||
|
||||
case "/report":
|
||||
if (!isValidToken(parameters)) {
|
||||
return new NanoHTTPD.Response(NanoHTTPD.Response.Status.FORBIDDEN,
|
||||
"text/html", "<h1>403 Forbidden - Invalid or missing token</h1>");
|
||||
}
|
||||
String days = parameters.get("days");
|
||||
if (days == null) days = "1";
|
||||
answer = ReportManager.generateHtmlReport(
|
||||
LogManager.mOsdDb, Integer.parseInt(days));
|
||||
responseMimeType = "text/html";
|
||||
break;
|
||||
|
||||
case "/report/download":
|
||||
if (!isValidToken(parameters)) {
|
||||
return new NanoHTTPD.Response(NanoHTTPD.Response.Status.FORBIDDEN,
|
||||
"text/html", "<h1>403 Forbidden - Invalid or missing token</h1>");
|
||||
}
|
||||
String dlDays = parameters.get("days");
|
||||
if (dlDays == null) dlDays = "1";
|
||||
String reportHtml = ReportManager.generateHtmlReport(
|
||||
LogManager.mOsdDb, Integer.parseInt(dlDays));
|
||||
NanoHTTPD.Response dlRes = new NanoHTTPD.Response(
|
||||
NanoHTTPD.Response.Status.OK, "text/html", reportHtml);
|
||||
dlRes.addHeader("Content-Disposition",
|
||||
"attachment; filename=\"seizure_report.html\"");
|
||||
return dlRes;
|
||||
|
||||
default:
|
||||
if (uri.startsWith("/index.html") ||
|
||||
uri.startsWith("/logfiles.html") ||
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
package uk.org.openseizuredetector;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.text.format.Time;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.github.mikephil.charting.charts.BarChart;
|
||||
import com.github.mikephil.charting.components.XAxis;
|
||||
import com.github.mikephil.charting.components.YAxis;
|
||||
import com.github.mikephil.charting.data.BarData;
|
||||
import com.github.mikephil.charting.data.BarDataSet;
|
||||
import com.github.mikephil.charting.data.BarEntry;
|
||||
import com.github.mikephil.charting.utils.ValueFormatter;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
/**
|
||||
* Test/validation screen for measuring a known simulated acceleration frequency.
|
||||
*
|
||||
* This screen does not perform seizure detection. It binds to SdServer, watches the latest 5 second
|
||||
* BLE2 rawData blocks already being produced by the app, accumulates 30/60 seconds, then runs a
|
||||
* longer-window FFT and displays/saves the result.
|
||||
*/
|
||||
public class SensorValidationActivity extends AppCompatActivity {
|
||||
private static final String TAG = "SensorValidation";
|
||||
private static final double SAMPLE_RATE_HZ = 25.0;
|
||||
private static final int RAW_BLOCK_SECONDS = 5;
|
||||
|
||||
private OsdUtil mUtil;
|
||||
private SdServiceConnection mConnection;
|
||||
private final Handler mHandler = new Handler();
|
||||
private Timer mUiTimer;
|
||||
private Context mContext;
|
||||
|
||||
private EditText expectedHzEt;
|
||||
private EditText toleranceEt;
|
||||
private Spinner durationSpinner;
|
||||
private Button startButton;
|
||||
private Button stopButton;
|
||||
private Button saveRawButton;
|
||||
private Button saveSpectrumButton;
|
||||
private Button startHrButton;
|
||||
private Button stopHrButton;
|
||||
private Button saveHrButton;
|
||||
private TextView statusTv;
|
||||
private TextView progressTv;
|
||||
private TextView resultTv;
|
||||
private TextView hrProgressTv;
|
||||
private TextView hrResultTv;
|
||||
private BarChart chart;
|
||||
|
||||
private boolean collecting = false;
|
||||
private int targetSeconds = 30;
|
||||
private long lastBlockTimeMillis = -1;
|
||||
private long collectionStartMillis = -1;
|
||||
private double lastAnalysisDurationSeconds = 0.0;
|
||||
private int collectedBlockCount = 0;
|
||||
private final ArrayList<Double> sampleBuffer = new ArrayList<>();
|
||||
private FrequencyValidationAnalyzer.Result lastResult = null;
|
||||
|
||||
private boolean collectingHr = false;
|
||||
private long hrStartMillis = -1;
|
||||
private int currentHrSecond = -1;
|
||||
private double currentHrSum = 0.0;
|
||||
private int currentHrValidCount = 0;
|
||||
private int currentHrReadCount = 0;
|
||||
private final ArrayList<HeartRatePoint> hrPoints = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Log.i(TAG, "onCreate()");
|
||||
mContext = this;
|
||||
mUtil = new OsdUtil(getApplicationContext(), mHandler);
|
||||
mConnection = new SdServiceConnection(getApplicationContext());
|
||||
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
setContentView(R.layout.activity_sensor_validation);
|
||||
|
||||
expectedHzEt = (EditText) findViewById(R.id.validationExpectedHzEt);
|
||||
toleranceEt = (EditText) findViewById(R.id.validationToleranceEt);
|
||||
durationSpinner = (Spinner) findViewById(R.id.validationDurationSpinner);
|
||||
startButton = (Button) findViewById(R.id.validationStartButton);
|
||||
stopButton = (Button) findViewById(R.id.validationStopButton);
|
||||
saveRawButton = (Button) findViewById(R.id.validationSaveRawButton);
|
||||
saveSpectrumButton = (Button) findViewById(R.id.validationSaveSpectrumButton);
|
||||
startHrButton = (Button) findViewById(R.id.validationStartHrButton);
|
||||
stopHrButton = (Button) findViewById(R.id.validationStopHrButton);
|
||||
saveHrButton = (Button) findViewById(R.id.validationSaveHrButton);
|
||||
statusTv = (TextView) findViewById(R.id.validationStatusTv);
|
||||
progressTv = (TextView) findViewById(R.id.validationProgressTv);
|
||||
resultTv = (TextView) findViewById(R.id.validationResultTv);
|
||||
hrProgressTv = (TextView) findViewById(R.id.validationHrProgressTv);
|
||||
hrResultTv = (TextView) findViewById(R.id.validationHrResultTv);
|
||||
chart = (BarChart) findViewById(R.id.validationChart);
|
||||
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||
android.R.layout.simple_spinner_item,
|
||||
new String[]{"30 seconds", "60 seconds"});
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
durationSpinner.setAdapter(adapter);
|
||||
durationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||
targetSeconds = position == 0 ? 30 : 60;
|
||||
updateProgressText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> parent) {
|
||||
targetSeconds = 30;
|
||||
}
|
||||
});
|
||||
|
||||
startButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
startValidationTest();
|
||||
}
|
||||
});
|
||||
stopButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
stopValidationTest("Stopped by user.");
|
||||
}
|
||||
});
|
||||
saveRawButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
saveRawCsv();
|
||||
}
|
||||
});
|
||||
saveSpectrumButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
saveSpectrumCsv();
|
||||
}
|
||||
});
|
||||
startHrButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
startHeartRateTest();
|
||||
}
|
||||
});
|
||||
stopHrButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
stopHeartRateTest("HR test stopped by user.");
|
||||
}
|
||||
});
|
||||
saveHrButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
saveHeartRateCsv();
|
||||
}
|
||||
});
|
||||
|
||||
configureChart();
|
||||
updateButtons();
|
||||
updateProgressText();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
Log.i(TAG, "onStart()");
|
||||
if (mUtil.isServerRunning()) {
|
||||
mUtil.bindToServer(getApplicationContext(), mConnection);
|
||||
} else {
|
||||
statusTv.setText("Background service is not running. Start the OSD service before validation.");
|
||||
statusTv.setBackgroundColor(Color.rgb(245, 158, 11));
|
||||
}
|
||||
|
||||
mUiTimer = new Timer();
|
||||
mUiTimer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
mHandler.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
pollLatestDataBlock();
|
||||
pollHeartRateSample();
|
||||
updateStatusText();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 0, 500);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
Log.i(TAG, "onStop()");
|
||||
if (mUiTimer != null) {
|
||||
mUiTimer.cancel();
|
||||
mUiTimer = null;
|
||||
}
|
||||
mUtil.unbindFromServer(getApplicationContext(), mConnection);
|
||||
}
|
||||
|
||||
private void startValidationTest() {
|
||||
if (!mConnection.mBound || mConnection.mSdServer == null) {
|
||||
mUtil.showToast("Cannot start: background service is not connected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (collectingHr) {
|
||||
stopHeartRateTest("HR test stopped because accelerometer validation started.");
|
||||
}
|
||||
|
||||
sampleBuffer.clear();
|
||||
lastResult = null;
|
||||
lastAnalysisDurationSeconds = 0.0;
|
||||
collectedBlockCount = 0;
|
||||
|
||||
// Start from the next fresh data block so we do not immediately count an old 5-second
|
||||
// block that arrived before the user pressed Start.
|
||||
SdData current = mConnection.mSdServer.mSdData;
|
||||
lastBlockTimeMillis = current != null ? getDataTimeMillis(current) : -1;
|
||||
collectionStartMillis = System.currentTimeMillis();
|
||||
|
||||
collecting = true;
|
||||
resultTv.setText("Collecting validation data... Waiting for fresh BLE2 data blocks.");
|
||||
chart.clear();
|
||||
updateButtons();
|
||||
updateProgressText();
|
||||
}
|
||||
|
||||
private void stopValidationTest(String message) {
|
||||
collecting = false;
|
||||
updateButtons();
|
||||
if (message != null) {
|
||||
resultTv.setText(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void pollLatestDataBlock() {
|
||||
if (!collecting) return;
|
||||
if (!mConnection.mBound || mConnection.mSdServer == null || mConnection.mSdServer.mSdData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
SdData sdData = mConnection.mSdServer.mSdData;
|
||||
if (!sdData.haveData || sdData.rawData == null || sdData.mNsamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
long blockTime = getDataTimeMillis(sdData);
|
||||
if (blockTime <= 0 || blockTime == lastBlockTimeMillis) {
|
||||
return;
|
||||
}
|
||||
lastBlockTimeMillis = blockTime;
|
||||
|
||||
int nSamples = Math.min(sdData.mNsamp, sdData.rawData.length);
|
||||
for (int i = 0; i < nSamples; i++) {
|
||||
sampleBuffer.add(sdData.rawData[i]);
|
||||
}
|
||||
collectedBlockCount++;
|
||||
|
||||
updateProgressText();
|
||||
|
||||
long elapsedMillis = collectionStartMillis > 0
|
||||
? System.currentTimeMillis() - collectionStartMillis
|
||||
: 0;
|
||||
if (elapsedMillis >= targetSeconds * 1000L && sampleBuffer.size() >= 4) {
|
||||
collecting = false;
|
||||
lastAnalysisDurationSeconds = elapsedMillis / 1000.0;
|
||||
runLongWindowAnalysis();
|
||||
updateButtons();
|
||||
}
|
||||
}
|
||||
|
||||
private long getDataTimeMillis(SdData sdData) {
|
||||
try {
|
||||
Time t = sdData.dataTime;
|
||||
if (t != null) {
|
||||
return t.toMillis(false);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void runLongWindowAnalysis() {
|
||||
try {
|
||||
double expectedHz = parseDoubleOrDefault(expectedHzEt.getText().toString(), 3.0);
|
||||
double tolerancePct = parseDoubleOrDefault(toleranceEt.getText().toString(), 5.0);
|
||||
|
||||
double[] samples = new double[sampleBuffer.size()];
|
||||
for (int i = 0; i < sampleBuffer.size(); i++) {
|
||||
samples[i] = sampleBuffer.get(i);
|
||||
}
|
||||
|
||||
double analysisSampleRate = SAMPLE_RATE_HZ;
|
||||
if (lastAnalysisDurationSeconds > 0.0) {
|
||||
analysisSampleRate = samples.length / lastAnalysisDurationSeconds;
|
||||
}
|
||||
|
||||
lastResult = FrequencyValidationAnalyzer.analyze(samples, analysisSampleRate, expectedHz, tolerancePct);
|
||||
showResult(lastResult);
|
||||
updateChart(lastResult);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Validation analysis failed: " + e.toString());
|
||||
resultTv.setText("Validation analysis failed: " + e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private double parseDoubleOrDefault(String str, double defaultValue) {
|
||||
try {
|
||||
return Double.parseDouble(str.trim());
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private void showResult(FrequencyValidationAnalyzer.Result result) {
|
||||
DecimalFormat df2 = new DecimalFormat("0.00");
|
||||
DecimalFormat df3 = new DecimalFormat("0.000");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(result.pass ? "PASS" : "FAIL").append("\n\n");
|
||||
sb.append("Expected frequency: ").append(df2.format(result.expectedHz)).append(" Hz\n");
|
||||
sb.append("Measured frequency: ").append(df3.format(result.measuredHz)).append(" Hz\n");
|
||||
sb.append("Error: ").append(df3.format(result.errorHz)).append(" Hz\n");
|
||||
sb.append("Error percent: ").append(df2.format(result.errorPercent)).append("%\n");
|
||||
sb.append("Tolerance: ±").append(df2.format(result.tolerancePercent)).append("%\n");
|
||||
sb.append("Actual elapsed window: ").append(df2.format(result.durationSeconds)).append(" seconds\n");
|
||||
sb.append("Samples: ").append(result.sampleCount).append("\n");
|
||||
sb.append("Data blocks: ").append(collectedBlockCount).append("\n");
|
||||
if (collectedBlockCount > 0) {
|
||||
sb.append("Average block length: ").append(df2.format(result.durationSeconds / collectedBlockCount)).append(" seconds\n");
|
||||
sb.append("Average samples/block: ").append(df2.format(result.sampleCount / (double) collectedBlockCount)).append("\n");
|
||||
}
|
||||
sb.append("Estimated sample rate: ").append(df2.format(result.sampleRateHz)).append(" Hz\n");
|
||||
if (Math.abs(result.sampleRateHz - SAMPLE_RATE_HZ) > 2.0) {
|
||||
sb.append("Note: using estimated sample rate, not the original 25 Hz assumption.\n");
|
||||
}
|
||||
sb.append("Peak power: ").append(df2.format(result.peakPower));
|
||||
resultTv.setText(sb.toString());
|
||||
}
|
||||
|
||||
private void configureChart() {
|
||||
chart.setDrawBarShadow(false);
|
||||
chart.setNoDataTextDescription("Run a validation test to show the long-window spectrum.");
|
||||
chart.setDescription("");
|
||||
|
||||
XAxis xAxis = chart.getXAxis();
|
||||
xAxis.setPosition(XAxis.XAxisPosition.BOTTOM);
|
||||
xAxis.setTextSize(9f);
|
||||
xAxis.setDrawAxisLine(true);
|
||||
xAxis.setDrawLabels(true);
|
||||
xAxis.setDrawGridLines(false);
|
||||
|
||||
YAxis left = chart.getAxisLeft();
|
||||
left.setAxisMinValue(0f);
|
||||
YAxis right = chart.getAxisRight();
|
||||
right.setEnabled(false);
|
||||
}
|
||||
|
||||
private void updateChart(FrequencyValidationAnalyzer.Result result) {
|
||||
ArrayList<String> xVals = new ArrayList<>();
|
||||
ArrayList<BarEntry> yVals = new ArrayList<>();
|
||||
int[] colors = new int[result.displaySpectrum.size()];
|
||||
|
||||
for (int i = 0; i < result.displaySpectrum.size(); i++) {
|
||||
FrequencyValidationAnalyzer.SpectrumBar bar = result.displaySpectrum.get(i);
|
||||
xVals.add(bar.label());
|
||||
yVals.add(new BarEntry((float) bar.power, i));
|
||||
if (result.expectedHz >= bar.freqStartHz && result.expectedHz < bar.freqEndHz) {
|
||||
colors[i] = Color.rgb(239, 68, 68);
|
||||
} else {
|
||||
colors[i] = Color.rgb(96, 165, 250);
|
||||
}
|
||||
}
|
||||
|
||||
BarDataSet dataSet = new BarDataSet(yVals, "Long-window spectrum");
|
||||
dataSet.setColors(colors);
|
||||
dataSet.setBarSpacePercent(20f);
|
||||
BarData data = new BarData(xVals, dataSet);
|
||||
data.setValueFormatter(new ValueFormatter() {
|
||||
@Override
|
||||
public String getFormattedValue(float v) {
|
||||
DecimalFormat format = new DecimalFormat("####");
|
||||
return format.format(v);
|
||||
}
|
||||
});
|
||||
chart.setData(data);
|
||||
chart.invalidate();
|
||||
}
|
||||
|
||||
private void updateStatusText() {
|
||||
if (!mConnection.mBound || mConnection.mSdServer == null) {
|
||||
statusTv.setText("Service: not connected");
|
||||
statusTv.setBackgroundColor(Color.rgb(245, 158, 11));
|
||||
return;
|
||||
}
|
||||
SdData sdData = mConnection.mSdServer.mSdData;
|
||||
String dataSource = mConnection.mSdServer.mSdDataSourceName;
|
||||
String watchStatus = (sdData != null && sdData.watchConnected) ? "connected" : "not connected";
|
||||
statusTv.setText("Service: connected | Datasource: " + dataSource + " | Watch: " + watchStatus);
|
||||
statusTv.setBackgroundColor(Color.rgb(37, 99, 235));
|
||||
statusTv.setTextColor(Color.WHITE);
|
||||
}
|
||||
|
||||
private void updateProgressText() {
|
||||
int collectedSamples = sampleBuffer.size();
|
||||
double elapsedSeconds = 0.0;
|
||||
if (collecting && collectionStartMillis > 0) {
|
||||
elapsedSeconds = (System.currentTimeMillis() - collectionStartMillis) / 1000.0;
|
||||
} else if (lastAnalysisDurationSeconds > 0.0) {
|
||||
elapsedSeconds = lastAnalysisDurationSeconds;
|
||||
}
|
||||
|
||||
double estimatedRate = elapsedSeconds > 0.0 ? collectedSamples / elapsedSeconds : 0.0;
|
||||
double averageBlockSeconds = collectedBlockCount > 0 ? elapsedSeconds / collectedBlockCount : 0.0;
|
||||
|
||||
progressTv.setText("Progress: "
|
||||
+ String.format(Locale.UK, "%.1f", elapsedSeconds)
|
||||
+ " / " + targetSeconds + " sec elapsed, "
|
||||
+ collectedSamples + " samples, "
|
||||
+ collectedBlockCount + " blocks, "
|
||||
+ "estimated rate " + String.format(Locale.UK, "%.1f", estimatedRate)
|
||||
+ " Hz, avg block " + String.format(Locale.UK, "%.2f", averageBlockSeconds)
|
||||
+ " sec");
|
||||
}
|
||||
|
||||
private void updateButtons() {
|
||||
startButton.setEnabled(!collecting && !collectingHr);
|
||||
stopButton.setEnabled(collecting);
|
||||
saveRawButton.setEnabled(lastResult != null);
|
||||
saveSpectrumButton.setEnabled(lastResult != null);
|
||||
startHrButton.setEnabled(!collecting && !collectingHr);
|
||||
stopHrButton.setEnabled(collectingHr);
|
||||
saveHrButton.setEnabled(!hrPoints.isEmpty() && !collectingHr);
|
||||
}
|
||||
|
||||
|
||||
private void startHeartRateTest() {
|
||||
if (!mConnection.mBound || mConnection.mSdServer == null) {
|
||||
mUtil.showToast("Cannot start HR test: background service is not connected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (collecting) {
|
||||
stopValidationTest("Accelerometer validation stopped because HR test started.");
|
||||
}
|
||||
|
||||
hrPoints.clear();
|
||||
currentHrSecond = -1;
|
||||
currentHrSum = 0.0;
|
||||
currentHrValidCount = 0;
|
||||
currentHrReadCount = 0;
|
||||
hrStartMillis = System.currentTimeMillis();
|
||||
collectingHr = true;
|
||||
|
||||
hrResultTv.setText("Collecting heart-rate data...");
|
||||
updateHeartRateProgressText();
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
private void stopHeartRateTest(String message) {
|
||||
if (collectingHr) {
|
||||
flushCurrentHeartRateSecond();
|
||||
}
|
||||
collectingHr = false;
|
||||
updateHeartRateProgressText();
|
||||
showHeartRateSummary(message);
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
private void pollHeartRateSample() {
|
||||
if (!collectingHr) return;
|
||||
if (!mConnection.mBound || mConnection.mSdServer == null || mConnection.mSdServer.mSdData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
double elapsedSeconds = (now - hrStartMillis) / 1000.0;
|
||||
int secondIndex = (int) Math.floor(elapsedSeconds);
|
||||
|
||||
if (secondIndex >= targetSeconds) {
|
||||
stopHeartRateTest(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentHrSecond != secondIndex) {
|
||||
flushCurrentHeartRateSecond();
|
||||
currentHrSecond = secondIndex;
|
||||
currentHrSum = 0.0;
|
||||
currentHrValidCount = 0;
|
||||
currentHrReadCount = 0;
|
||||
}
|
||||
|
||||
double hr = mConnection.mSdServer.mSdData.mHR;
|
||||
currentHrReadCount++;
|
||||
if (hr >= 0.0) {
|
||||
currentHrSum += hr;
|
||||
currentHrValidCount++;
|
||||
}
|
||||
|
||||
updateHeartRateProgressText();
|
||||
}
|
||||
|
||||
private void flushCurrentHeartRateSecond() {
|
||||
if (currentHrSecond < 0) return;
|
||||
|
||||
double averageHr = currentHrValidCount > 0 ? currentHrSum / currentHrValidCount : -1.0;
|
||||
long timestampMillis = hrStartMillis + currentHrSecond * 1000L;
|
||||
String timestampIso = timestampIsoLocal(timestampMillis);
|
||||
String timestamp = new SimpleDateFormat("HH:mm:ss", Locale.UK).format(new Date(timestampMillis));
|
||||
|
||||
hrPoints.add(new HeartRatePoint(
|
||||
hrPoints.size(),
|
||||
timestampIso,
|
||||
timestampMillis,
|
||||
timestamp,
|
||||
currentHrSecond,
|
||||
averageHr,
|
||||
currentHrValidCount,
|
||||
currentHrReadCount));
|
||||
}
|
||||
|
||||
private void updateHeartRateProgressText() {
|
||||
double elapsedSeconds = 0.0;
|
||||
if (collectingHr && hrStartMillis > 0) {
|
||||
elapsedSeconds = (System.currentTimeMillis() - hrStartMillis) / 1000.0;
|
||||
} else if (!hrPoints.isEmpty()) {
|
||||
elapsedSeconds = hrPoints.size();
|
||||
}
|
||||
|
||||
hrProgressTv.setText("HR progress: "
|
||||
+ String.format(Locale.UK, "%.1f", elapsedSeconds)
|
||||
+ " / " + targetSeconds
|
||||
+ " sec, " + hrPoints.size()
|
||||
+ " one-second points saved");
|
||||
}
|
||||
|
||||
private void showHeartRateSummary(String message) {
|
||||
if (hrPoints.isEmpty()) {
|
||||
hrResultTv.setText(message != null ? message : "No HR data collected.");
|
||||
return;
|
||||
}
|
||||
|
||||
double sum = 0.0;
|
||||
double min = Double.MAX_VALUE;
|
||||
double max = -Double.MAX_VALUE;
|
||||
int validSeconds = 0;
|
||||
int totalReadings = 0;
|
||||
int validReadings = 0;
|
||||
|
||||
for (HeartRatePoint point : hrPoints) {
|
||||
totalReadings += point.readingsInSecond;
|
||||
validReadings += point.validReadingsInSecond;
|
||||
if (point.valid) {
|
||||
sum += point.averageHr;
|
||||
min = Math.min(min, point.averageHr);
|
||||
max = Math.max(max, point.averageHr);
|
||||
validSeconds++;
|
||||
}
|
||||
}
|
||||
|
||||
DecimalFormat df2 = new DecimalFormat("0.00");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (message != null) {
|
||||
sb.append(message).append("\n\n");
|
||||
}
|
||||
sb.append("Heart-rate data collected.\n\n");
|
||||
sb.append("Window: ").append(targetSeconds).append(" seconds\n");
|
||||
sb.append("One-second points: ").append(hrPoints.size()).append("\n");
|
||||
sb.append("Seconds with valid HR: ").append(validSeconds).append("\n");
|
||||
sb.append("Total reads: ").append(totalReadings).append("\n");
|
||||
sb.append("Valid reads: ").append(validReadings).append("\n");
|
||||
if (validSeconds > 0) {
|
||||
sb.append("Average HR: ").append(df2.format(sum / validSeconds)).append(" bpm\n");
|
||||
sb.append("Min HR: ").append(df2.format(min)).append(" bpm\n");
|
||||
sb.append("Max HR: ").append(df2.format(max)).append(" bpm");
|
||||
} else {
|
||||
sb.append("Average HR: N/A");
|
||||
}
|
||||
hrResultTv.setText(sb.toString());
|
||||
}
|
||||
|
||||
private void saveHeartRateCsv() {
|
||||
if (hrPoints.isEmpty()) return;
|
||||
File file = new File(getOutputDir(), "validation_hr_" + timestampForFilename() + ".csv");
|
||||
try {
|
||||
OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file));
|
||||
writer.write("point_index,timestamp_iso,epoch_ms,time_hhmmss,elapsed_seconds,avg_hr_bpm,valid,valid_readings_in_second,total_readings_in_second\n");
|
||||
for (HeartRatePoint point : hrPoints) {
|
||||
writer.write(point.index + ","
|
||||
+ point.timestampIso + ","
|
||||
+ point.epochMillis + ","
|
||||
+ point.timestampHhMmSs + ","
|
||||
+ point.elapsedSecond + ","
|
||||
+ (point.valid ? String.format(Locale.UK, "%.3f", point.averageHr) : "") + ","
|
||||
+ point.valid + ","
|
||||
+ point.validReadingsInSecond + ","
|
||||
+ point.readingsInSecond + "\n");
|
||||
}
|
||||
writer.close();
|
||||
mUtil.showToast("Saved HR CSV: " + file.getAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error saving HR CSV: " + e.toString());
|
||||
mUtil.showToast("Error saving HR CSV: " + e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static class HeartRatePoint {
|
||||
final int index;
|
||||
final String timestampIso;
|
||||
final long epochMillis;
|
||||
final String timestampHhMmSs;
|
||||
final int elapsedSecond;
|
||||
final double averageHr;
|
||||
final boolean valid;
|
||||
final int validReadingsInSecond;
|
||||
final int readingsInSecond;
|
||||
|
||||
HeartRatePoint(int index,
|
||||
String timestampIso,
|
||||
long epochMillis,
|
||||
String timestampHhMmSs,
|
||||
int elapsedSecond,
|
||||
double averageHr,
|
||||
int validReadingsInSecond,
|
||||
int readingsInSecond) {
|
||||
this.index = index;
|
||||
this.timestampIso = timestampIso;
|
||||
this.epochMillis = epochMillis;
|
||||
this.timestampHhMmSs = timestampHhMmSs;
|
||||
this.elapsedSecond = elapsedSecond;
|
||||
this.averageHr = averageHr;
|
||||
this.valid = validReadingsInSecond > 0;
|
||||
this.validReadingsInSecond = validReadingsInSecond;
|
||||
this.readingsInSecond = readingsInSecond;
|
||||
}
|
||||
}
|
||||
|
||||
private File getOutputDir() {
|
||||
File dir = new File(getExternalFilesDir(null), "sensor_validation");
|
||||
if (!dir.exists()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
dir.mkdirs();
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
private String timestampForFilename() {
|
||||
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.UK).format(new Date());
|
||||
}
|
||||
|
||||
private static String timestampIsoLocal(long millis) {
|
||||
// ISO-like local timestamp with timezone offset, for example: 2026-05-05T10:24:25.000+0000
|
||||
return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.UK).format(new Date(millis));
|
||||
}
|
||||
|
||||
private void saveRawCsv() {
|
||||
if (lastResult == null) return;
|
||||
File file = new File(getOutputDir(), "validation_raw_" + timestampForFilename() + ".csv");
|
||||
try {
|
||||
OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file));
|
||||
writer.write("sample_index,time_seconds,accel_magnitude\n");
|
||||
for (int i = 0; i < lastResult.rawSamples.length; i++) {
|
||||
double t = i / lastResult.sampleRateHz;
|
||||
writer.write(i + "," + String.format(Locale.UK, "%.4f", t) + ","
|
||||
+ String.format(Locale.UK, "%.6f", lastResult.rawSamples[i]) + "\n");
|
||||
}
|
||||
writer.close();
|
||||
mUtil.showToast("Saved raw CSV: " + file.getAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error saving raw CSV: " + e.toString());
|
||||
mUtil.showToast("Error saving raw CSV: " + e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveSpectrumCsv() {
|
||||
if (lastResult == null) return;
|
||||
File file = new File(getOutputDir(), "validation_spectrum_" + timestampForFilename() + ".csv");
|
||||
try {
|
||||
OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file));
|
||||
writer.write("expected_hz,measured_hz,error_hz,error_percent,tolerance_percent,pass,sample_rate_hz,sample_count,duration_seconds,block_count,average_block_seconds\n");
|
||||
double averageBlockSeconds = collectedBlockCount > 0 ? lastResult.durationSeconds / collectedBlockCount : 0.0;
|
||||
writer.write(String.format(Locale.UK, "%.6f,%.6f,%.6f,%.6f,%.6f,%s,%.6f,%d,%.6f,%d,%.6f\n",
|
||||
lastResult.expectedHz,
|
||||
lastResult.measuredHz,
|
||||
lastResult.errorHz,
|
||||
lastResult.errorPercent,
|
||||
lastResult.tolerancePercent,
|
||||
lastResult.pass ? "PASS" : "FAIL",
|
||||
lastResult.sampleRateHz,
|
||||
lastResult.sampleCount,
|
||||
lastResult.durationSeconds,
|
||||
collectedBlockCount,
|
||||
averageBlockSeconds));
|
||||
writer.write("\nfrequency_start_hz,frequency_end_hz,power\n");
|
||||
for (FrequencyValidationAnalyzer.SpectrumBar bar : lastResult.displaySpectrum) {
|
||||
writer.write(String.format(Locale.UK, "%.6f,%.6f,%.6f\n",
|
||||
bar.freqStartHz, bar.freqEndHz, bar.power));
|
||||
}
|
||||
writer.close();
|
||||
mUtil.showToast("Saved spectrum CSV: " + file.getAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error saving spectrum CSV: " + e.toString());
|
||||
mUtil.showToast("Error saving spectrum CSV: " + e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,17 +183,79 @@ public class StartupActivity extends AppCompatActivity {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Enable the "Install Watch App" button if we have the Pebble data source selected,
|
||||
// otherwise hide it.
|
||||
b = (Button) findViewById(R.id.installOsdAppButton);
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
String dataSourceName = (prefs.getString("DataSource", "Phone"));
|
||||
if (dataSourceName.equals("Pebble")) {
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Log.v(TAG, "install Osd Watch App button clicked");
|
||||
if (mConnection.mSdServer.mSdDataSource != null) {
|
||||
mUtil.writeToSysLogFile("Installing Watch App");
|
||||
mConnection.mSdServer.mSdDataSource.installWatchApp();
|
||||
} else {
|
||||
mUtil.showToast("Error installing watch app - Datasource has not started - please see installation instructions on web site");
|
||||
Log.v(TAG, "Displaying Installation Instructions");
|
||||
try {
|
||||
String url = "https://www.openseizuredetector.org.uk/?page_id=1894";
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setData(Uri.parse(url));
|
||||
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(i);
|
||||
} catch (Exception ex) {
|
||||
Log.i(TAG, "exception starting install watch app activity " + ex.toString());
|
||||
mUtil.showToast("Error Displaying Installation Instructions - try http://www.openseizuredetector.org.uk/?page_id=1894 instead");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
b.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
b = (Button) findViewById(R.id.instructionsButton);
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Log.v(TAG, "install Osd Watch App button clicked");
|
||||
mUtil.writeToSysLogFile("Installing Watch App");
|
||||
mConnection.mSdServer.mSdDataSource.installWatchApp();
|
||||
Log.v(TAG, "instructions button clicked");
|
||||
try {
|
||||
String url = "https://www.openseizuredetector.org.uk/?page_id=1894";
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setData(Uri.parse(url));
|
||||
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(i);
|
||||
} catch (Exception ex) {
|
||||
Log.v(TAG, "exception displaying instructions " + ex.toString());
|
||||
mUtil.showToast("ERROR Displaying Instructions");
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
b = (Button) findViewById(R.id.troubleshootingButton);
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Log.v(TAG, "troubleshooting button clicked");
|
||||
try {
|
||||
String url = "https://www.openseizuredetector.org.uk/?page_id=2235";
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setData(Uri.parse(url));
|
||||
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(i);
|
||||
} catch (Exception ex) {
|
||||
Log.v(TAG, "exception displaying troubleshooting " + ex.toString());
|
||||
mUtil.showToast("ERROR Displaying Troubleshooting Tips");
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Connect to the background service
|
||||
mConnection = new SdServiceConnection(getApplicationContext());
|
||||
|
||||
}
|
||||
|
||||
@@ -369,13 +369,13 @@ public class WebApiConnection_osdapi extends WebApiConnection {
|
||||
new Response.Listener<String>() {
|
||||
@Override
|
||||
public void onResponse(String response) {
|
||||
Log.v(TAG, "Response is: " + response);
|
||||
Log.v(TAG, "updateEvent.onResponse(): Response is: " + response);
|
||||
mServerConnectionOk = true;
|
||||
try {
|
||||
JSONObject retObj = new JSONObject(response);
|
||||
callback.accept(retObj);
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "getEventTypes.onRespons(): Error: " + e.getMessage() + "," + e.toString());
|
||||
Log.e(TAG, "updateEvent.onResponse(): Error: " + e.getMessage() + "," + e.toString());
|
||||
callback.accept(null);
|
||||
}
|
||||
}
|
||||
@@ -385,9 +385,9 @@ public class WebApiConnection_osdapi extends WebApiConnection {
|
||||
public void onErrorResponse(VolleyError error) {
|
||||
mServerConnectionOk = false;
|
||||
if (error != null) {
|
||||
Log.e(TAG, "Create Event Error: " + error.toString() + ", message:" + error.getMessage());
|
||||
Log.e(TAG, "updateEvent.onErrorResponse(): Error: " + error.toString() + ", message:" + error.getMessage());
|
||||
} else {
|
||||
Log.e(TAG, "Create Event Error - returned null response");
|
||||
Log.e(TAG, "updateEvent.onErrorResponse(): Error - returned null response");
|
||||
}
|
||||
callback.accept(null);
|
||||
}
|
||||
|
||||
@@ -1,91 +1,102 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/root_layout_export_data"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Export Local Data"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="End Date/Time (dd-mm-yyyy hh:mm)" />
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context="uk.org.openseizuredetector.LogManager">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/export_data_content_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Export Local Data"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="End Date/Time (dd-mm-yyyy hh:mm)" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/endDateText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:text="(end date)" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/dateBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Select Date" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/endTimeText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:text="(end time)" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/timeBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Select Time" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Duration (hrs)" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/endDateText"
|
||||
android:id="@+id/durationText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:text="(end date)" />
|
||||
android:inputType="numberDecimal"
|
||||
android:text="1.0" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/dateBtn"
|
||||
android:layout_width="wrap_content"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Select Date" />
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/exportBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Export Data" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/exportPb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="invisible" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/endTimeText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:text="(end time)" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/timeBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Select Time" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Duration (hrs)" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/durationText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:inputType="numberDecimal"
|
||||
android:text="1.0" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/exportBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Export Data" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/exportPb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="invisible" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,170 +1,184 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:orientation="vertical">
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/root_layout_edit_event"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context="uk.org.openseizuredetector.LogManager">
|
||||
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<LinearLayout
|
||||
android:id="@+id/edit_event_content_layout"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancelBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cancel" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/loginBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/save" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="fill_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:orientation="horizontal">
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
<Button
|
||||
android:id="@+id/cancelBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cancel" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/eventid" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": " />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/eventIdTv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="[id]" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
<Button
|
||||
android:id="@+id/loginBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_date"
|
||||
android:textSize="20sp" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": "
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/eventDateTv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="..."
|
||||
android:textSize="20sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/alarm_state"
|
||||
android:textSize="20sp" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": "
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/eventAlarmStateTv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="..."
|
||||
android:textSize="20sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_type"
|
||||
android:textSize="20sp" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": "
|
||||
android:textSize="20sp" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/eventTypeRg"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"></RadioGroup>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_sub_type"
|
||||
android:textSize="20sp" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": "
|
||||
android:textSize="20sp" />
|
||||
|
||||
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/eventSubTypeRg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"></RadioGroup>
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/eventNotsTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginRight="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:hint="@string/notes_about_event" />
|
||||
|
||||
|
||||
android:layout_weight="1"
|
||||
android:text="@string/save" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/eventid" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": " />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/eventIdTv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="[id]" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_date"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": "
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/eventDateTv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="..."
|
||||
android:textSize="20sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/alarm_state"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": "
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/eventAlarmStateTv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="..."
|
||||
android:textSize="20sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_type"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": "
|
||||
android:textSize="20sp" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/eventTypeRg"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"></RadioGroup>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_sub_type"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=": "
|
||||
android:textSize="20sp" />
|
||||
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/eventSubTypeRg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"></RadioGroup>
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/eventNotsTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginRight="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:hint="@string/notes_about_event" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,218 +1,248 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/root_layout_log_manager_control"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/activity_vertical_margin"
|
||||
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context="uk.org.openseizuredetector.LogManager">
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/local_database"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/num_local_events" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/num_local_events_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="000" />
|
||||
</LinearLayout>
|
||||
|
||||
<!--
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/num_local_datapoints" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/num_local_datapoints_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="000" />
|
||||
</LinearLayout>
|
||||
-->
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/NDATimeRemaining" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/nda_time_remaining_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="000" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<RadioGroup xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/shared_data_rb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true"
|
||||
android:onClick="onRadioButtonClicked"
|
||||
android:text="@string/shared_data" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/local_data_rb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false"
|
||||
android:onClick="onRadioButtonClicked"
|
||||
android:text="@string/local_data" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/syslog_rb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false"
|
||||
android:onClick="onRadioButtonClicked"
|
||||
android:text="@string/system_logs" />
|
||||
</RadioGroup>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/shared_data_ll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="visible">
|
||||
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/activity_vertical_margin"
|
||||
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||
android:id="@+id/log_manager_control_content_layout"
|
||||
tools:context="uk.org.openseizuredetector.LogManager">
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/remote_database"
|
||||
android:text="@string/local_database"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/check_seizures_message" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/authStatusTv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/not_authenticated" />
|
||||
android:text="@string/num_local_events" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/auth_button"
|
||||
<TextView
|
||||
android:id="@+id/num_local_events_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/authenticate" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/refreshBtn" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/remoteAccessPb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent" />
|
||||
android:text="000" />
|
||||
</LinearLayout>
|
||||
|
||||
<!--
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/num_local_datapoints" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/num_local_datapoints_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="000" />
|
||||
</LinearLayout>
|
||||
-->
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/include_warnings_cb"
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/include_warnings"></CheckBox>
|
||||
android:text="@string/NDATimeRemaining" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/include_nda_cb"
|
||||
<TextView
|
||||
android:id="@+id/nda_time_remaining_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/include_nda"></CheckBox>
|
||||
android:text="000" />
|
||||
</LinearLayout>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/remoteEventsLv"
|
||||
|
||||
<RadioGroup xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp" />
|
||||
android:orientation="horizontal">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/shared_data_rb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true"
|
||||
android:onClick="onRadioButtonClicked"
|
||||
android:text="@string/shared_data" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/local_data_rb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false"
|
||||
android:onClick="onRadioButtonClicked"
|
||||
android:text="@string/local_data" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/syslog_rb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false"
|
||||
android:onClick="onRadioButtonClicked"
|
||||
android:text="@string/system_logs" />
|
||||
</RadioGroup>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/shared_data_ll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="visible">
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/remote_database"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/check_seizures_message" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/authStatusTv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/not_authenticated" /> <!-- Space after TextView -->
|
||||
|
||||
<Button
|
||||
android:id="@+id/auth_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/authenticate" /> <!-- Space after this button -->
|
||||
|
||||
<Button
|
||||
android:id="@+id/refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/refreshBtn" /> <!-- Space before this button -->
|
||||
<!-- No marginEnd needed if ProgressBar is last and you want it close,
|
||||
or add marginEnd if you want space before ProgressBar too -->
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/remoteAccessPb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp" /> <!-- Space before ProgressBar -->
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/group_events_cb"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:checked="true"
|
||||
android:text="@string/group_remote_events" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/include_warnings_cb"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/include_warnings" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/include_nda_cb"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/include_nda" />
|
||||
</LinearLayout>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/remoteEventsLv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/local_data_ll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/EventsInLocalDb"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/eventLogListView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/syslog_ll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/system_logs"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/sysLogListView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/local_data_ll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/EventsInLocalDb"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/eventLogListView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/syslog_ll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/system_logs"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/sysLogListView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/activity_main2_root_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context="uk.org.openseizuredetector.LogManager">
|
||||
|
||||
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:orientation="vertical">
|
||||
<LinearLayout
|
||||
android:id="@+id/activity_main2_content_layout"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/versionTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name" />
|
||||
<TextView
|
||||
android:id="@+id/versionTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name" />
|
||||
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/fragment_common_container_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/fragment_common_container_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/fragment_pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</LinearLayout>
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/fragment_pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</LinearLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,183 +1,193 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/root_layout_report_seizure"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/activity_vertical_margin"
|
||||
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context="uk.org.openseizuredetector.LogManager">
|
||||
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/report_seizure"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<ScrollView
|
||||
<LinearLayout
|
||||
android:id="@+id/report_seizure_content_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/activity_vertical_margin"
|
||||
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||
tools:context="uk.org.openseizuredetector.LogManager">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:text="@string/report_seizure"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/date" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_day_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="dd" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:text="/" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_mon_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="mm" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:text="/" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_year_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="yyyy" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/select_date_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/select_date" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/time" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_hh_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="hh" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:text=":" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_mm_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="mm" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/select_time_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/select_time" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_type"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/eventTypeRg"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"></RadioGroup>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_sub_type"
|
||||
android:textSize="20sp" />
|
||||
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/eventSubTypeRg"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"></RadioGroup>
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/eventNotesTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginRight="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:hint="notes about event" />
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/loginBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/okBtnTxt" />
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancelBtn"
|
||||
android:layout_width="wrap_content"
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/date" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_day_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="dd" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:text="/" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_mon_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="mm" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:text="/" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_year_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="yyyy" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/select_date_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/select_date" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/cancelBtnTxt" />
|
||||
</LinearLayout>
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/msg_tv"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="msg" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/time" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_hh_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="hh" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:text=":" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_mm_tv"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="mm" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/select_time_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/select_time" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_type"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/eventTypeRg"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"></RadioGroup>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/event_sub_type"
|
||||
android:textSize="20sp" />
|
||||
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/eventSubTypeRg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"></RadioGroup>
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/eventNotesTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginRight="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:hint="notes about event" />
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/loginBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/okBtnTxt" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancelBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/cancelBtnTxt" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/msg_tv"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="msg" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
251
app/src/main/res/layout/activity_sensor_validation.xml
Normal file
251
app/src/main/res/layout/activity_sensor_validation.xml
Normal file
@@ -0,0 +1,251 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#f1f5f9"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sensor Validation"
|
||||
android:textColor="#111827"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Measure long-window accelerometer frequency output or collect timestamped heart-rate data. This does not affect seizure detection."
|
||||
android:textColor="#4b5563"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/validationStatusTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="#f59e0b"
|
||||
android:padding="10dp"
|
||||
android:text="Service: not connected"
|
||||
android:textColor="#ffffff"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:background="#ffffff"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Accelerometer frequency test"
|
||||
android:textColor="#111827"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="Expected frequency (Hz)" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/validationExpectedHzEt"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal"
|
||||
android:text="3.0" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="Pass tolerance (%)" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/validationToleranceEt"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal"
|
||||
android:text="5.0" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="Validation window" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/validationDurationSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/validationStartButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Start Accel" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/validationStopButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Stop Accel" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/validationProgressTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="Progress: 0 / 750 samples"
|
||||
android:textColor="#374151" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:background="#ffffff"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Heart-rate monitor test"
|
||||
android:textColor="#111827"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Uses the same 30/60 second window. Saves one timestamped heart-rate datapoint per second using the average of the HR reads seen during that second."
|
||||
android:textColor="#4b5563"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/validationStartHrButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Start HR" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/validationStopHrButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Stop HR" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/validationHrProgressTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="HR progress: 0.0 / 30 sec, 0 one-second points saved"
|
||||
android:textColor="#374151" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/validationHrResultTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="No HR test run yet."
|
||||
android:textColor="#111827"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/validationSaveHrButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="Save HR CSV" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:background="#ffffff"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Result"
|
||||
android:textColor="#111827"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/validationResultTv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="No test run yet."
|
||||
android:textColor="#111827"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<com.github.mikephil.charting.charts.BarChart
|
||||
android:id="@+id/validationChart"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="320dp"
|
||||
android:layout_marginTop="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/validationSaveRawButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Save Raw CSV" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/validationSaveSpectrumButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Save Spectrum CSV" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
42
app/src/main/res/layout/grouped_event_entry_layout.xml
Normal file
42
app/src/main/res/layout/grouped_event_entry_layout.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:orientation="vertical" android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/date"
|
||||
android:id="@+id/group_event_time_tv" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=" : "/>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="alarm"
|
||||
android:id="@+id/group_event_alarmState_tv"
|
||||
/>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=" : "/>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="uploaded"
|
||||
android:id="@+id/group_event_uploaded"
|
||||
/>
|
||||
|
||||
</LinearLayout>
|
||||
<!-- <TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="dataJSON"
|
||||
android:id="@+id/event_dataJSON" />
|
||||
-->
|
||||
</LinearLayout>
|
||||
@@ -35,12 +35,36 @@
|
||||
android:layout_gravity="center_horizontal" />
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:text="Help"
|
||||
android:id="@+id/instructionsButton"
|
||||
android:layout_gravity="center_horizontal" />
|
||||
|
||||
<Button
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:text="Trouble-\nshooting"
|
||||
android:id="@+id/troubleshootingButton"
|
||||
android:layout_gravity="center_horizontal" />
|
||||
|
||||
<Button
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:text="@string/edit_settings"
|
||||
android:id="@+id/settingsButton"
|
||||
android:layout_gravity="center_horizontal" />
|
||||
|
||||
@@ -100,11 +100,23 @@
|
||||
android:icon="@drawable/ic_action_settings"
|
||||
app:showAsAction="never|withText"
|
||||
android:title="@string/settings" />
|
||||
<item
|
||||
android:id="@+id/action_sensor_validation"
|
||||
android:title="Sensor Validation Test"
|
||||
android:showAsAction="never|withText" />
|
||||
|
||||
</group>
|
||||
|
||||
<group android:id="@+id/grp6">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_instructions"
|
||||
app:showAsAction="never|withText"
|
||||
android:title="Help" />
|
||||
<item
|
||||
android:id="@+id/action_troubleshooting"
|
||||
app:showAsAction="never|withText"
|
||||
android:title="Troubleshooting" />
|
||||
<item
|
||||
android:id="@+id/action_about"
|
||||
app:showAsAction="never|withText"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
<string name="app_name">OpenSeizureDetector</string>
|
||||
<string name="changelog">
|
||||
"\n
|
||||
\nV4.3.1 - Fixed user interface issues on Android-15 and Android-16
|
||||
\nV4.3.0 - Added support for Android 15 (API 35) and above.
|
||||
\n - Simplified data sharing editor by grouping events for editing.
|
||||
\nV4.2.12 - Added buttons and menu items for 'Help' and 'Troubleshooting' to point users to the web page instructions.
|
||||
\nV4.2 - Added support for PineTime and BangleJS Watches using Bluetooth data source.
|
||||
\n - Added support for Version 2 of the Garmin watch app, which reduces battery drain
|
||||
\n - Added new, swipeable user interface to simplify the main screen.
|
||||
@@ -101,11 +105,11 @@
|
||||
<string name="AlarmRatioThreshTitle">Alarm Ratio Threshold</string>
|
||||
<string name="AlarmRatioThreshSummary">Alarm Ratio Threshold (Default = 57). Increase to reduce sensitivity.</string>
|
||||
<string name="AlarmFreqMaxTitle">AlarmFreqMax (Hz)</string>
|
||||
<string name="AlarmFreqMaxSummary">Maximum Frequency of ROI (Hz) (Default = 8 Hz)</string>
|
||||
<string name="AlarmFreqMaxSummary">Maximum Frequency of ROI (Hz) ([Floga] Default = 5 Hz)</string>
|
||||
<string name="AlarmFreqMinTitle">AlarmFreqMin (Hz)</string>
|
||||
<string name="AlarmFreqMinSummary">Minimum Frequency of ROI (Hz) (Default = 3 Hz)</string>
|
||||
<string name="HRAlarmEnabledTitle">Enable Simple Heart Rate Alarm</string>
|
||||
<string name="HRAlarmEnabledSummary" />
|
||||
<string name="HRAlarmEnabledSummary">Floga: Triggers alarm if heart rate rises by 20% during detected seizure motion, reducing false alarms</string>
|
||||
<string name="HRNullAlarmTitle">Treat a Null heart rate as an alarm condition</string>
|
||||
<string name="HRNullAlarmSummary" />
|
||||
<string name="HRThreshMinTitle">Heart Rate Min Threshold (bpm)</string>
|
||||
@@ -299,7 +303,7 @@
|
||||
<string name="SMSAlarmSettingsTitle">SMS Alarm Settings</string>
|
||||
<string name="DefaultSMSMsgText">**SEIZURE DETECTED**</string>
|
||||
<string name="AlarmLoggingTitle">Alarm Logging</string>
|
||||
<string name="WarnTimeSummary">Time to wait before initiating warning (Default = 5 sec)</string>
|
||||
<string name="WarnTimeSummary">Time to wait before initiating warning ([Floga] Default = 10 sec)</string>
|
||||
<string name="WarnTimeTitle">WarnTime (sec)</string>
|
||||
<string name="AlarmTimeSummary">Time to wait before initiating alarm (Default = 10 sec)</string>
|
||||
<string name="AlarmTimeTitle">AlarmTime (sec)</string>
|
||||
@@ -482,7 +486,7 @@
|
||||
<string name="no_button_title">NO</string>
|
||||
<string name="privacy_policy_button_title">Privacy Policy</string>
|
||||
<string name="data_sharing_button_title">Data Sharing</string>
|
||||
<string name="HRAdaptiveAlarmEnabledTitle">Enable Adaptive Threshold Heart Rate Alarm</string>
|
||||
<string name="HRAdaptiveAlarmEnabledTitle"> [Floga] Enable Adaptive Threshold Heart Rate Alarm</string>
|
||||
<string name="HRAdaptiveAlarmWindowTitle">Window Size for Adaptive Threshold Calculation</string>
|
||||
<string name="HRAdaptiveAlarmWindowSummary">Moving Average Time Window (seconds) must be a multiple of 5 seconds</string>
|
||||
<string name="HRAdaptiveThreshTitle">Adaptive Threshold Offset (bpm)</string>
|
||||
@@ -587,4 +591,5 @@
|
||||
<string name="DefaultSMSFalseAlarmMsgText">False Alarm, Sorry!</string>
|
||||
<string name="sms_false_alarm_message_summary">Text of \'False Alarm\' SMS message</string>
|
||||
<string name="sms_false_alarm_message_title">SMS False Alarm Message</string>
|
||||
<string name="group_remote_events">Group Remote Events</string>
|
||||
</resources>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
android:summary="@string/HRAlarmEnabledSummary"
|
||||
android:title="@string/HRAlarmEnabledTitle" />
|
||||
<CheckBoxPreference
|
||||
android:defaultValue="false"
|
||||
android:defaultValue="true"
|
||||
android:key="HRAdaptiveAlarmActive"
|
||||
android:summary="@string/HRAlarmEnabledSummary"
|
||||
android:title="@string/HRAdaptiveAlarmEnabledTitle" />
|
||||
@@ -50,8 +50,8 @@
|
||||
android:summary="@string/OsdAlarmEnabledSummary"
|
||||
android:title="@string/OsdAlarmEnabledTitle" />
|
||||
<EditTextPreference
|
||||
android:defaultValue="5"
|
||||
android:key="WarnTime"
|
||||
android:defaultValue="10"
|
||||
android:key="[Floga] WarnTime (sec)"
|
||||
android:summary="@string/WarnTimeSummary"
|
||||
android:title="@string/WarnTimeTitle" />
|
||||
<EditTextPreference
|
||||
@@ -59,6 +59,11 @@
|
||||
android:key="AlarmTime"
|
||||
android:summary="@string/AlarmTimeSummary"
|
||||
android:title="@string/AlarmTimeTitle" />
|
||||
<CheckBoxPreference
|
||||
android:defaultValue="true"
|
||||
android:key="FlogaEmergencyEscalation"
|
||||
android:summary="Call emergency contacts if seizure lasts longer than 5 minutes"
|
||||
android:title="[Floga] Enable 5 Minute Emergency Call" />
|
||||
<EditTextPreference
|
||||
android:defaultValue="100"
|
||||
android:key="AlarmThresh"
|
||||
@@ -76,8 +81,8 @@
|
||||
android:summary="@string/AlarmFreqMinSummary"
|
||||
android:title="@string/AlarmFreqMinTitle" />
|
||||
<EditTextPreference
|
||||
android:defaultValue="8"
|
||||
android:key="AlarmFreqMax"
|
||||
android:defaultValue="5"
|
||||
android:key="[Floga] AlarmFreqMax (Hz)"
|
||||
android:summary="@string/AlarmFreqMaxSummary"
|
||||
android:title="@string/AlarmFreqMaxTitle" />
|
||||
|
||||
@@ -168,7 +173,7 @@
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory android:title="@string/HeartRateAdaptiveAlarmSettingsTitle">
|
||||
<CheckBoxPreference
|
||||
android:defaultValue="false"
|
||||
android:defaultValue="true"
|
||||
android:key="HRAdaptiveAlarmActive"
|
||||
android:summary="@string/HRAlarmEnabledSummary"
|
||||
android:title="@string/HRAdaptiveAlarmEnabledTitle" />
|
||||
|
||||
@@ -10,7 +10,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.4.1'
|
||||
classpath 'com.android.tools.build:gradle:8.8.0'
|
||||
classpath 'com.google.gms:google-services:4.3.15'
|
||||
}
|
||||
}
|
||||
|
||||
BIN
doc/Data_Sharing_Grouping.png
Normal file
BIN
doc/Data_Sharing_Grouping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Thu Oct 10 20:04:34 BST 2024
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
releases/app-release-4.3.0a.apk
Normal file
BIN
releases/app-release-4.3.0a.apk
Normal file
Binary file not shown.
BIN
releases/app-release-4.3.1.apk
Normal file
BIN
releases/app-release-4.3.1.apk
Normal file
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user