Skip to content

Commit 88363fa

Browse files
Accessibility audit, release signing config, and Play Store beta listing prep (#18)
* Phase 11 Task 1: defensive profile parsing and tap test patient advocate carryovers ProfileSetupScreen now uses toIntOrNull and toDoubleOrNull with return@Button sentinels around dob year and height parsing, hoisted out of the scope.launch coroutine builder so the sentinel resolves at the onClick lambda level. Closes the Phase 2 Code Reviewer minor finding on toInt and toDouble crash exposure. BilateralTapTest absorbs four Patient Advocate Phase 2 findings: pre instructions copy replaced with "tap with the hand you usually write or text with first, then the other hand" framing (Finding 1); a new RestingBetweenRounds state with a 5 second countdown sits between the two rounds (Finding 2); the spacer between the two 120 dp tap circles widens from 16 dp to 32 dp (Finding 4); the Done state now blocks on an explicit Continue button rather than auto routing back to the battery (Finding 5). Mid test "Dominant hand" and "Non dominant hand" role labels left unchanged because they identify which round is currently active and serve a different function from the introductory framing; flagged for the Patient Advocate's Phase 11 phase close ratification at Task 17. Test suite 428 to 435: 2 new ProfileSetupScreen tests, 3 new BilateralTapTestResting tests using mainClock.autoAdvance = false plus advanceTimeBy, 2 new BilateralTapTestDone tests. 0 failures, 0 errors, 4 pre existing skipped. * Phase 11 Task 3: chart content description, sparkline, and pdf paint hoists TrendLineChart wraps the per recomposition content description build in a remember keyed on (series, displayLabel, context); the join over points and the unit suffix resource lookup now run only when an input changes. Closes Phase 9 Performance Engineer finding P9-M1. ReportsScreen TestSummaryCardView wraps the FeatureSeries constructor in a remember keyed on (primaryFeatureSparkline, displayName) so the sparkline points and Path are not rebuilt on every recomposition. Closes Phase 9 Performance Engineer finding P9-M2. PdfChartRenderer hoists four Paint instances (linePaint, markerPaint, markerNotchPaint, axisPaint) to instance fields, eliminating the per render and per marker and per axis label allocation churn flagged at Phase 10 Performance Engineer finding P10-M1. drawMarker resets strokeWidth to 0 on FILL paths so a prior LOW band marker stroke does not leak into a subsequent HIGH or MEDIUM band marker. CsvExporter.toCsv drops the unused sessionIdByResultId parameter; the single production caller in ReportExporter and the two test sites collapse to match. FaceDistanceAnalyzer.processFaces drops the unused frameWidth parameter; the one internal call site in analyze and the three test sites collapse to match. Closes two pre existing kotlinc unused parameter warnings. A handful of brief items were already closed in prior PRs and required no edits this pass: PE M5 (RawSensorWriter rowBuffer landed in PR 7), PE M4 (transient Quaternion in quaternionFromAndroidRotationVector already returns a single allocation in PR 7), Code Reviewer Phase 3 L2 L3 L4 (kotlin.math.sin removed, Math.PI replaced, double support time docstring rewritten in PR 8), and Phase 10 PE M3 (CsvExporter SimpleDateFormat is already hoisted out of the per row loop). The Phase 10 PE M2 bodyPaint was reported as dead but is in use inside the chartsForPage branch in renderTestSection. Test suite 435, 0 failures. assembleDebug BUILD SUCCESSFUL with zero new warnings. * Phase 11 Task 4: sensor trace writer interface, cancel drain, and Madgwick first sample hold SensorTraceWriter interface extracts the public surface (appendRow, close) from RawSensorWriter so the test path can swap in an in memory FakeSensorTraceWriter without subclassing the file backed writer. RawSensorWriter stays final and implements the interface; the convenience write(Flow) entry point is preserved for the existing RawSensorWriterTest. Closes SPE Phase 4 finding I3. GaitTestViewModel onCancel now drains the in flight sample buffer via cancelAndJoin before the writer closes; the writer's finally block guarantees close happens once whether the path ends normally or by cancellation. A new SensorCancelTailEdgeTest emits 10 samples, cancels mid stream, and asserts the writer received every sample before close. Closes SPE Phase 4 finding I1. AndroidImuSource adds a first sample hold on the Madgwick fallback path. When the rotation vector sensor is absent and lastAccel has not arrived, the source sets firstSampleHoldStartedAtMs on the very first emission and skips Madgwick updates until either the accelerometer arrives within 50 ms (the filter steps with the real gravity reading) or 50 ms elapses (the filter falls back to the linear surrogate so the pipeline does not stall). The source accepts an injectable nowMs supplier so tests can drive virtual time; the default uses System.currentTimeMillis. Closes SPE Phase 4 finding I2. Test suite grows by 2 net (1 new SensorCancelTailEdgeTest plus 1 net from splitting the prior Madgwick fallback test into two falsifiable tests of the hold contract). Robolectric reporter intermittently fails XML emission under parallel workers; --max-workers=1 produces a clean BUILD SUCCESSFUL. * Phase 11 Task 5: atomic rename, render failure cleanup, and opportunistic stale tmp cleanup ReportExporter swaps File.renameTo for Files.move with StandardCopyOption ATOMIC_MOVE on both the PDF and CSV write paths. On AtomicMoveNotSupportedException the path falls back to Files.move with REPLACE_EXISTING and increments a process local AtomicLong exposed via ReportExporter.atomicMoveFallbackCount so future diagnostics can detect a fallback path without the report package logging (K.7 no log rule is preserved by using a counter rather than Log or println). Both write blocks are now wrapped in try/finally and the .tmp file is deleted in the finally block before the canonical file rename, so a render exception no longer leaves stranded .tmp files in cacheDir/exports/. ReportPdfRenderer opens its class and render method so the new ReportExporterRenderFailureTest can inject a subclass that throws IOException("simulated render failure") from render and assert that no .tmp file remains after the exception propagates. At the top of every export call, cleanupStaleTempFiles globs cacheDir/exports for .tmp files older than STALE_TMP_AGE_MS (60 seconds) and deletes each. The new ReportExporterTmpCleanupTest pre creates two .tmp files, one 120 s old and one 30 s old, runs export, and asserts the older file is gone and the newer one stays. The test wraps the export call in a try/catch for the Robolectric FileProvider SimplePathStrategy static cache bleed that surfaces only in full suite test ordering; the cleanup runs first so the assertions hold regardless of how the export call exits. docs/security/hardening-checklist.md Section K.6 rewritten and marked verified closed 2026-05-18 with the four implementing and verifying file paths cited. Test suite at 439 passing, 0 failures, 4 ignored. assembleDebug BUILD SUCCESSFUL. * Phase 11 Task 2: phase 4 patient advocate gait carryovers and abandoned session lifecycle GaitInstructionsScreen now carries a mobility aid messaging paragraph hoisted to strings.xml as gait_test_instructions_mobility_aid_body. The copy was narrowed at Clinical Validator ratification: the original "aided walking is still measurable" claim was rewritten to a narrower cadence rhythm claim, the list expanded to include crutches (clinically relevant for MS EDSS 6 to 6.5 ambulation), the "same aid each time" constraint added to make the within device trend honesty load bearing, and the ZUPT calibration bias disclosed explicitly ("Absolute stride length values are calibrated for unaided walking and may read low when an aid is used"). Closes PA Phase 4 Finding 3. GaitCaptureScreen subscribes to a process scoped GaitVolumeCancelBus while the capture is in Running state. MainActivity intercepts KEYCODE_VOLUME_DOWN and forwards key down and key up events to the bus, which fires a cancelRequested counter only when the hold exceeds 1500 ms. While the hold is in progress the capture screen surfaces an in screen hint plus a live progress fill so the user can see the gesture working when they pull the phone out of the pocket. The on screen Cancel button stays in place for pre walk and post walk. No sensor or audio focus path is touched; the bus is gated to the gait capture state machine so volume controls work normally everywhere else. Closes PA Phase 4 Finding 1. GaitCancelledScreen joins the project's Phase 6 and Phase 8 cancelled screen pattern with a single "Test cancelled. Nothing was saved." line and a Back to home button. The state machine routes Cancel paths to it rather than to the zero quality Done screen so a cancelled session never presents per feature values. Closes PA Phase 4 Finding 2. GaitDoneScreen confidence line for high quality completions reads "Walk recorded. Trends are most reliable across several weeks." matching the warmth register of the Voice and SDMT Done screens. Closes PA Phase 4 Finding 5. SessionEntity grows a wasCancelled Boolean column via Room migration v2 to v3. SessionRunnerScreen runs a DisposableEffect that on dispose, if the orchestrator is still in Running, calls BatteryOrchestrator.cancelSession() which stamps the in flight SessionEntity with completedAtEpochMs equal to now and wasCancelled equal to true. SessionDao.reclaimStrandedSessions(nowEpochMs, strandedBeforeEpochMs) runs from MainActivity onCreate to repair any session left at completedAtEpochMs IS NULL more than 60 seconds in the past. HomeScreen SessionRow renders "Cancelled", "Completed", or "In progress" per state. The existing explicit BatteryOrchestrator.cancel() that deletes the row stays intact so the "Stop and discard" dialog promise survives. Test suite 439 to 453 (plus 14): GaitTestLifecycleTest 6 covers cancelSession and reclaim semantics; GaitVolumeCancelBusTest 5 covers gating and threshold; GaitCancelledScreenTest 1 smoke; Migration2To3Test 1 verifies the new column; SessionDaoTest 1 verifies observeCompletedSessionCount excludes cancelled. assembleDebug BUILD SUCCESSFUL. * Phase 11 Task 9: release signing config wired with keystore.properties externalization app/build.gradle.kts gains a signingConfigs.release block that reads storeFile, storePassword, keyAlias, keyPassword from app/keystore.properties when the file exists. If any required key is null in a present file the config block throws a GradleException naming the missing property. A gradle.taskGraph.whenReady guard inside android {} checks the task graph for assembleRelease, bundleRelease, or packageRelease and throws a GradleException with a clear "Missing app/keystore.properties for the release signing config. Copy app/keystore.properties.example to app/keystore.properties and fill in the four values. See docs/observability/signing-runbook.md for the keystore generation steps." when the file is absent. Debug builds are unaffected. app/keystore.properties.example committed as the template: four placeholder lines with set this guidance for both passwords and the baselinems alias. The real app/keystore.properties is gitignored alongside *.jks and *.keystore. The keystore itself is NOT generated by this commit. Generation is a user driven step per the docs/observability/signing-runbook.md runbook because password handling is a personal decision. The release path becomes available to the user as soon as they run the keytool command in the runbook and populate keystore.properties from a password manager entry. assembleDebug BUILD SUCCESSFUL. assembleRelease BUILD FAILED with the expected clear error message rather than a NullPointerException. * Phase 11 Task 8: accessibility specialist must fix items absorbed VoiceRunnerScreen mic icon now decorative (contentDescription = null); the adjacent visible Text already carries the recording announcement, so TalkBack now reads "Recording. N s remaining." once rather than twice. Closes MF1. VoiceAudioQualityCheckScreen band copy carries a Modifier.semantics live region with LiveRegionMode.Polite so TalkBack announces band transitions (Pending to Green or Yellow or Red, Yellow to Green and back) as they happen within the screen rather than only on initial focus. Closes MF2. TrendLineChart Canvas DrawScope replaces raw pixel constants 4f 8f 3f with density independent values via Dp.toPx() inside the DrawScope (which itself implements Density). 2 dp line width, 4 dp marker radius, 1.5 dp marker stroke. The chart now scales correctly across display densities and at 200 percent Dynamic Type. Closes MF3. TrendLineChart sparkline branch attaches Modifier.semantics with invisibleToUser when no human readable content description is provided, so TalkBack does not announce raw point data when the sparkline sits inside a Card whose content description already carries the per series summary. The @OptIn(ExperimentalComposeUiApi::class) annotation gates the experimental invisibleToUser API. Closes MF4. PdfPageLayout FONT_SIZE_CHART_AXIS raised from 10f to 11f. The 49 char axis label at 11 pt SANS_SERIF stays under the 504 pt content width; the 1 pt chart plot area offset is absorbed within CHART_HEIGHT_POINTS = 180f. The 12 pt body floor still sits clearly above the axis annotation. Closes MF5. Test suite 453, 0 failures. assembleDebug BUILD SUCCESSFUL. Zero new warnings. * Phase 11 Task 11 polish: citation MISMATCH absorption NONAN GaitPrint first author corrected from Likens to Wiles in README, web/public/validation-summary.json, and web/app/methodology/page.tsx. MAREA DOI corrected from 10.1016/j.gaitpost.2016.09.027 (which resolves to an unrelated paper on tibial shaft fractures) to the correct value 10.1016/j.gaitpost.2016.09.023 in web/public/validation-summary.json. Oh 2024 title in README replaced with the published verbatim title. Madgwick.kt KDoc corrected to acknowledge the deliberate default beta = 0.1 deviation from the paper's IMU optimal beta = 0.033 (Madgwick 2010 Section 5) with the project's load bearing empirical evidence cited (Phase 3 fixture accuracy plus Phase 5 NONAN result). Citation Auditor Task 11 verdict APPROVED WITH MINOR FINDINGS. No runtime behavior changes; documentation only edit on Madgwick.kt plus public artifact corrections.
1 parent 0891f80 commit 88363fa

57 files changed

Lines changed: 1968 additions & 211 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ The gait pipeline is validated against three publicly downloadable IMU gait data
6464
| Dataset | Mount | N | Trials | Cadence MAE | Stride MAE | Cadence ICC(3,1) | Stride ICC(3,1) |
6565
|---|---|---|---|---|---|---|---|
6666
| Santos et al. 2022 | Leg strap (smartphone) | 25 | 499 | 19.53% | 66.36% | 0.573 | 0.806 |
67-
| NONAN GaitPrint (Likens et al. 2023) | Pelvis (pocket analog) | 35 | 609 | **0.53%** | 86.83% | **0.946** | 0.650 |
67+
| NONAN GaitPrint (Wiles et al. 2023) | Pelvis (pocket analog) | 35 | 609 | **0.53%** | 86.83% | **0.946** | 0.650 |
6868
| MAREA (Khandelwal and Wickstrom 2017) | Waist (accel only) | 20 | pending | pending | n/a | n/a | n/a |
6969

7070
### Cross sensor agreement
@@ -89,7 +89,7 @@ Reserved for the beta cohort retention numbers. Day 1, day 7, day 14, and day 30
8989

9090
The retention design and the gait pipeline rationale draw on two published analyses of the Floodlight Open dataset:
9191

92-
- Oh et al. 2024, *Scientific Reports*, "Floodlight Open, an open access global longitudinal smartphone based study of multiple sclerosis: Cohort characteristics and study design" (DOI 10.1038/s41598-023-49299-4).
92+
- Oh et al. 2024, *Scientific Reports*, "Use of smartphone-based remote assessments of multiple sclerosis in Floodlight Open, a global, prospective, open-access study" (DOI 10.1038/s41598-023-49299-4).
9393
- Galati et al. 2024, *JMIR Human Factors* 11:e57033, on retention and engagement in the Floodlight Open US cohort.
9494

9595
## Repository

app/build.gradle.kts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import java.util.Properties
2+
import java.io.FileInputStream
3+
14
plugins {
25
alias(libs.plugins.android.application)
36
alias(libs.plugins.jetbrains.kotlin.android)
@@ -19,10 +22,57 @@ android {
1922
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2023
}
2124

25+
signingConfigs {
26+
create("release") {
27+
val keystorePropsFile = rootProject.file("app/keystore.properties")
28+
if (keystorePropsFile.exists()) {
29+
val props = Properties().apply {
30+
FileInputStream(keystorePropsFile).use { stream -> load(stream) }
31+
}
32+
val storeFileProp = props.getProperty("storeFile")
33+
?: throw GradleException("app/keystore.properties is missing the storeFile property.")
34+
val storePasswordProp = props.getProperty("storePassword")
35+
?: throw GradleException("app/keystore.properties is missing the storePassword property.")
36+
val keyAliasProp = props.getProperty("keyAlias")
37+
?: throw GradleException("app/keystore.properties is missing the keyAlias property.")
38+
val keyPasswordProp = props.getProperty("keyPassword")
39+
?: throw GradleException("app/keystore.properties is missing the keyPassword property.")
40+
storeFile = rootProject.file(storeFileProp)
41+
storePassword = storePasswordProp
42+
keyAlias = keyAliasProp
43+
keyPassword = keyPasswordProp
44+
}
45+
}
46+
}
47+
2248
buildTypes {
2349
release {
2450
isMinifyEnabled = false
2551
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
52+
val keystorePropsFile = rootProject.file("app/keystore.properties")
53+
if (keystorePropsFile.exists()) {
54+
signingConfig = signingConfigs.getByName("release")
55+
} else {
56+
signingConfig = null
57+
}
58+
}
59+
}
60+
61+
gradle.taskGraph.whenReady {
62+
val hasReleaseTask = allTasks.any { task ->
63+
val name = task.name
64+
name.contains("Release", ignoreCase = true) &&
65+
(name.startsWith("assemble") || name.startsWith("bundle") || name.startsWith("package"))
66+
}
67+
if (hasReleaseTask) {
68+
val keystorePropsFile = rootProject.file("app/keystore.properties")
69+
if (!keystorePropsFile.exists()) {
70+
throw GradleException(
71+
"Missing app/keystore.properties for the release signing config. " +
72+
"Copy app/keystore.properties.example to app/keystore.properties and fill in the four values. " +
73+
"See docs/observability/signing-runbook.md for the keystore generation steps."
74+
)
75+
}
2676
}
2777
}
2878

app/keystore.properties.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Template for the BaselineMS release signing config.
2+
#
3+
# This file is a committed template. Copy it to app/keystore.properties
4+
# (which is gitignored) and fill in the real values from your password
5+
# manager. Never commit app/keystore.properties. Never paste the values
6+
# into chat, into a shared document, or into any plain text file outside
7+
# of app/keystore.properties itself.
8+
#
9+
# The storeFile path is resolved relative to the project root (the same
10+
# directory that contains settings.gradle.kts and the app/ folder), so
11+
# release-keystore.jks below points at app/release-keystore.jks. Both the
12+
# .jks file and keystore.properties are gitignored.
13+
#
14+
# See docs/observability/signing-runbook.md for the keytool command that
15+
# generates the keystore, password handling guidance, and the verification
16+
# steps after the keystore is in place.
17+
18+
storeFile=app/release-keystore.jks
19+
storePassword=<set this; minimum 12 characters>
20+
keyAlias=baselinems
21+
keyPassword=<set this; minimum 12 characters>
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
{
2+
"formatVersion": 1,
3+
"database": {
4+
"version": 3,
5+
"identityHash": "385d8c71037d16dee2d7c83753c2ef52",
6+
"entities": [
7+
{
8+
"tableName": "user_profile",
9+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dateOfBirthEpochMs` INTEGER NOT NULL, `biologicalSex` TEXT NOT NULL, `dominantHand` TEXT NOT NULL, `msTypeDisclosed` TEXT NOT NULL, `heightCm` REAL, `createdAtEpochMs` INTEGER NOT NULL, PRIMARY KEY(`id`))",
10+
"fields": [
11+
{
12+
"fieldPath": "id",
13+
"columnName": "id",
14+
"affinity": "TEXT",
15+
"notNull": true
16+
},
17+
{
18+
"fieldPath": "dateOfBirthEpochMs",
19+
"columnName": "dateOfBirthEpochMs",
20+
"affinity": "INTEGER",
21+
"notNull": true
22+
},
23+
{
24+
"fieldPath": "biologicalSex",
25+
"columnName": "biologicalSex",
26+
"affinity": "TEXT",
27+
"notNull": true
28+
},
29+
{
30+
"fieldPath": "dominantHand",
31+
"columnName": "dominantHand",
32+
"affinity": "TEXT",
33+
"notNull": true
34+
},
35+
{
36+
"fieldPath": "msTypeDisclosed",
37+
"columnName": "msTypeDisclosed",
38+
"affinity": "TEXT",
39+
"notNull": true
40+
},
41+
{
42+
"fieldPath": "heightCm",
43+
"columnName": "heightCm",
44+
"affinity": "REAL",
45+
"notNull": false
46+
},
47+
{
48+
"fieldPath": "createdAtEpochMs",
49+
"columnName": "createdAtEpochMs",
50+
"affinity": "INTEGER",
51+
"notNull": true
52+
}
53+
],
54+
"primaryKey": {
55+
"autoGenerate": false,
56+
"columnNames": [
57+
"id"
58+
]
59+
},
60+
"indices": [],
61+
"foreignKeys": []
62+
},
63+
{
64+
"tableName": "session",
65+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `startedAtEpochMs` INTEGER NOT NULL, `completedAtEpochMs` INTEGER, `deviceInfo` TEXT NOT NULL, `wasCancelled` INTEGER NOT NULL, PRIMARY KEY(`id`))",
66+
"fields": [
67+
{
68+
"fieldPath": "id",
69+
"columnName": "id",
70+
"affinity": "TEXT",
71+
"notNull": true
72+
},
73+
{
74+
"fieldPath": "startedAtEpochMs",
75+
"columnName": "startedAtEpochMs",
76+
"affinity": "INTEGER",
77+
"notNull": true
78+
},
79+
{
80+
"fieldPath": "completedAtEpochMs",
81+
"columnName": "completedAtEpochMs",
82+
"affinity": "INTEGER",
83+
"notNull": false
84+
},
85+
{
86+
"fieldPath": "deviceInfo",
87+
"columnName": "deviceInfo",
88+
"affinity": "TEXT",
89+
"notNull": true
90+
},
91+
{
92+
"fieldPath": "wasCancelled",
93+
"columnName": "wasCancelled",
94+
"affinity": "INTEGER",
95+
"notNull": true
96+
}
97+
],
98+
"primaryKey": {
99+
"autoGenerate": false,
100+
"columnNames": [
101+
"id"
102+
]
103+
},
104+
"indices": [],
105+
"foreignKeys": []
106+
},
107+
{
108+
"tableName": "test_result",
109+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `testType` TEXT NOT NULL, `startedAtEpochMs` INTEGER NOT NULL, `completedAtEpochMs` INTEGER NOT NULL, `qualityScore` REAL NOT NULL, `featuresJson` TEXT NOT NULL, `rawSensorRelativePath` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`session_id`) REFERENCES `session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
110+
"fields": [
111+
{
112+
"fieldPath": "id",
113+
"columnName": "id",
114+
"affinity": "TEXT",
115+
"notNull": true
116+
},
117+
{
118+
"fieldPath": "sessionId",
119+
"columnName": "session_id",
120+
"affinity": "TEXT",
121+
"notNull": true
122+
},
123+
{
124+
"fieldPath": "testType",
125+
"columnName": "testType",
126+
"affinity": "TEXT",
127+
"notNull": true
128+
},
129+
{
130+
"fieldPath": "startedAtEpochMs",
131+
"columnName": "startedAtEpochMs",
132+
"affinity": "INTEGER",
133+
"notNull": true
134+
},
135+
{
136+
"fieldPath": "completedAtEpochMs",
137+
"columnName": "completedAtEpochMs",
138+
"affinity": "INTEGER",
139+
"notNull": true
140+
},
141+
{
142+
"fieldPath": "qualityScore",
143+
"columnName": "qualityScore",
144+
"affinity": "REAL",
145+
"notNull": true
146+
},
147+
{
148+
"fieldPath": "featuresJson",
149+
"columnName": "featuresJson",
150+
"affinity": "TEXT",
151+
"notNull": true
152+
},
153+
{
154+
"fieldPath": "rawSensorRelativePath",
155+
"columnName": "rawSensorRelativePath",
156+
"affinity": "TEXT",
157+
"notNull": false
158+
}
159+
],
160+
"primaryKey": {
161+
"autoGenerate": false,
162+
"columnNames": [
163+
"id"
164+
]
165+
},
166+
"indices": [
167+
{
168+
"name": "index_test_result_session_id",
169+
"unique": false,
170+
"columnNames": [
171+
"session_id"
172+
],
173+
"orders": [],
174+
"createSql": "CREATE INDEX IF NOT EXISTS `index_test_result_session_id` ON `${TABLE_NAME}` (`session_id`)"
175+
}
176+
],
177+
"foreignKeys": [
178+
{
179+
"table": "session",
180+
"onDelete": "CASCADE",
181+
"onUpdate": "NO ACTION",
182+
"columns": [
183+
"session_id"
184+
],
185+
"referencedColumns": [
186+
"id"
187+
]
188+
}
189+
]
190+
}
191+
],
192+
"views": [],
193+
"setupQueries": [
194+
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
195+
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '385d8c71037d16dee2d7c83753c2ef52')"
196+
]
197+
}
198+
}

app/src/main/java/com/mustafanazeer/baselinems/BaselineMSApp.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.Application
44
import androidx.room.Room
55
import com.mustafanazeer.baselinems.data.AppDatabase
66
import com.mustafanazeer.baselinems.data.MIGRATION_1_2
7+
import com.mustafanazeer.baselinems.data.MIGRATION_2_3
78
import com.mustafanazeer.baselinems.signals.AndroidImuSource
89

910
class BaselineMSApp : Application() {
@@ -25,6 +26,6 @@ class BaselineMSApp : Application() {
2526
applicationContext,
2627
AppDatabase::class.java,
2728
"baselinems.db"
28-
).addMigrations(MIGRATION_1_2).build()
29+
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()
2930
}
3031
}

app/src/main/java/com/mustafanazeer/baselinems/MainActivity.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,47 @@ import androidx.activity.ComponentActivity
55
import androidx.activity.compose.setContent
66
import androidx.compose.material3.MaterialTheme
77
import androidx.compose.material3.Surface
8+
import androidx.lifecycle.lifecycleScope
9+
import com.mustafanazeer.baselinems.battery.gait.GaitVolumeCancelBus
810
import com.mustafanazeer.baselinems.ui.RootScreen
11+
import kotlinx.coroutines.launch
912

1013
class MainActivity : ComponentActivity() {
14+
15+
private val volumeBus: GaitVolumeCancelBus = GaitVolumeCancelBus
16+
1117
override fun onCreate(savedInstanceState: Bundle?) {
1218
super.onCreate(savedInstanceState)
19+
reclaimStrandedSessionsOnStart()
1320
setContent {
1421
MaterialTheme {
1522
Surface { RootScreen() }
1623
}
1724
}
1825
}
26+
27+
override fun onKeyDown(keyCode: Int, event: android.view.KeyEvent?): Boolean {
28+
if (event != null && volumeBus.dispatchVolumeKeyEvent(keyCode, event)) return true
29+
return super.onKeyDown(keyCode, event)
30+
}
31+
32+
override fun onKeyUp(keyCode: Int, event: android.view.KeyEvent?): Boolean {
33+
if (event != null && volumeBus.dispatchVolumeKeyEvent(keyCode, event)) return true
34+
return super.onKeyUp(keyCode, event)
35+
}
36+
37+
private fun reclaimStrandedSessionsOnStart() {
38+
val app = application as BaselineMSApp
39+
lifecycleScope.launch {
40+
val now = System.currentTimeMillis()
41+
app.database.sessionDao().reclaimStrandedSessions(
42+
nowEpochMs = now,
43+
strandedBeforeEpochMs = now - STRANDED_THRESHOLD_MS
44+
)
45+
}
46+
}
47+
48+
companion object {
49+
private const val STRANDED_THRESHOLD_MS: Long = 60_000L
50+
}
1951
}

app/src/main/java/com/mustafanazeer/baselinems/battery/BatteryOrchestrator.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ class BatteryOrchestrator(
8888
}
8989
}
9090

91+
fun cancelSession() {
92+
if (_state.value !is State.Running) return
93+
val sessionId = _activeSessionId ?: return
94+
_state.value = State.Idle
95+
_activeSessionId = null
96+
viewModelScope.launch {
97+
val session = sessionDao.getById(sessionId) ?: return@launch
98+
if (session.completedAtEpochMs != null) return@launch
99+
sessionDao.update(
100+
session.copy(
101+
completedAtEpochMs = System.currentTimeMillis(),
102+
wasCancelled = true
103+
)
104+
)
105+
}
106+
}
107+
91108
fun reset() {
92109
_activeSessionId = null
93110
_state.value = State.Idle

0 commit comments

Comments
 (0)