Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ object WearSyncEmulatorFixture {
textFile(
measurement = "EMULATOR_SINGLE_CSV",
name = "accelerometer.csv",
text = "t_sensor;t_unix;x;y;z;accuracy\n123;456;1.0;2.0;3.0;3\n",
text = "t_sensor;x;y;z;accuracy\n123;1.0;2.0;3.0;3\n",
),
),
),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ import java.io.File
internal data class RecordedSensor(
val type: Int,
val fileName: String,
val header: String = "t_sensor;t_unix;x;y;z;accuracy",
val columnCount: Int = 6,
val header: String = "t_sensor;x;y;z;accuracy",
val columnCount: Int = 5,
)

internal data class RecordingScenario(
Expand DownExpand Up@@ -345,6 +345,8 @@ internal class RecordingEmulatorFixture(private val context: Context) {

private fun assertSessionMetadata() {
assertEquals(scenario.name, metadata.getString("folder"))
assertTrue(metadata.getLong("millis") >= scheduledStartMillis - TIMESTAMP_TOLERANCE_MILLIS)
assertTrue(metadata.getLong("nanos") > 0L)
assertEquals(scenario.notes, metadata.getJSONArray("notes").toStringList())
assertEquals(scenario.durationMillis.coerceAtLeast(0), metadata.getLong("durationMillis"))
assertEquals(
Expand DownExpand Up@@ -408,13 +410,7 @@ internal class RecordingEmulatorFixture(private val context: Context) {
columns
}
val sensorTimestamps = samples.map { it[0].toLong() }
val unixTimestamps = samples.map { it[1].toLong() }
assertEquals(sensorTimestamps.sorted(), sensorTimestamps)
assertEquals(unixTimestamps.sorted(), unixTimestamps)
assertTrue(
"A sample was written before recording started: $unixTimestamps",
unixTimestamps.all { it >= scheduledStartMillis - TIMESTAMP_TOLERANCE_MILLIS },
)
}
}

Expand All@@ -434,7 +430,7 @@ internal class RecordingEmulatorFixture(private val context: Context) {
const val ACTIVITY_TRANSITIONS_FILE = "activity_transitions.csv"
const val ACTIVITY_TRANSITIONS_HEADER = "t_nanos;activity;enter_exit"
const val SIGNIFICANT_MOTION_FILE = "significant_motion.csv"
const val SIGNIFICANT_HEADER = "t_unix;event"
const val SIGNIFICANT_HEADER = "t_sensor;event"
const val MINIMUM_ROWS = 2
const val TIMESTAMP_TOLERANCE_MILLIS = 250L
const val FILE_WAIT_ATTEMPTS = 40
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,12 +62,16 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva
override suspend fun loadMeasurementFile(measurementId: String, fileId: String): AppResult<MeasurementFileContent> =
withContext(Dispatchers.IO) {
suspendAppResult(AppErrorCode.STORAGE, "Read measurement file") {
val document = measurementDirectory(measurementId).findFile(fileId)
val directory = measurementDirectory(measurementId)
val document = directory.findFile(fileId)
?.takeIf(DocumentFile::isFile)
?: error("Measurement file is unavailable")
when {
document.name.equals(GPS_FILE, ignoreCase = true) -> parseGpsCoordinates(document)
document.name.orEmpty().endsWith(CSV_EXTENSION, ignoreCase = true) -> parseSensorSeries(document)

document.name.orEmpty().endsWith(CSV_EXTENSION, ignoreCase = true) ->
parseSensorSeries(document, readSensorTimeAnchor(directory))

else -> parseTextFile(document)
}
}
Expand All@@ -88,11 +92,7 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva
?: throw IllegalArgumentException("Measurement does not exist")

private fun createMeasurementSummary(directory: DocumentFile): MeasurementSummary {
val metadata = directory.findFile(METADATA_FILE)
?.takeIf(DocumentFile::isFile)
?.let(::readDocumentText)
.orEmpty()
val json = metadata.takeIf(String::isNotBlank)?.let(::parseMetadataObject)
val json = readMetadata(directory)
return MeasurementSummary(
id = checkNotNull(directory.name),
name = directory.name.orEmpty(),
Expand DownExpand Up@@ -137,21 +137,32 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva
}
}

private fun parseSensorSeries(document: DocumentFile): MeasurementFileContent.SensorSeries {
private fun parseSensorSeries(
document: DocumentFile,
anchor: SensorTimeAnchor?,
): MeasurementFileContent.SensorSeries {
var columns = emptyList<String>()
val samples = mutableListOf<SensorSeriesSample>()
var totalSamples = 0
openDocumentStream(document).bufferedReader().useLines { lines ->
val iterator = lines.iterator()
if (!iterator.hasNext()) return@useLines
val header = iterator.next().split(DELIMITER)
val timestampIndex = header.indexOf("t_unix").takeIf { it >= 0 } ?: 0
val unixTimestampIndex = header.indexOf("t_unix").takeIf { it >= 0 }
val sensorTimestampIndex = header.indexOf("t_sensor").takeIf { it >= 0 }
val timestampIndex = unixTimestampIndex ?: sensorTimestampIndex ?: 0
val valueIndexes = header.indices.filter { index ->
index != timestampIndex && header[index] !in NON_VALUE_COLUMNS
}
columns = valueIndexes.map(header::get)
while (iterator.hasNext()) {
parseSensorSample(iterator.next(), timestampIndex, valueIndexes)?.let { sample ->
parseSensorSample(
line = iterator.next(),
timestampIndex = timestampIndex,
valueIndexes = valueIndexes,
sensorTimestamp = sensorTimestampIndex != null,
anchor = anchor,
)?.let { sample ->
totalSamples += 1
retainBounded(samples, sample, totalSamples, MAX_CHART_SAMPLES)
}
Expand All@@ -160,13 +171,42 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva
return MeasurementFileContent.SensorSeries(columns, samples, totalSamples > samples.size)
}

private fun parseSensorSample(line: String, timestampIndex: Int, valueIndexes: List<Int>): SensorSeriesSample? {
private fun parseSensorSample(
line: String,
timestampIndex: Int,
valueIndexes: List<Int>,
sensorTimestamp: Boolean,
anchor: SensorTimeAnchor?,
): SensorSeriesSample? {
val fields = line.split(DELIMITER)
val timestamp = fields.getOrNull(timestampIndex)?.toLongOrNull() ?: return null
val rawTimestamp = fields.getOrNull(timestampIndex)?.toLongOrNull() ?: return null
val timestamp = if (sensorTimestamp) {
anchor?.unixMillis?.plus((rawTimestamp - anchor.elapsedRealtimeNanos) / NANOS_PER_MILLISECOND)
?: rawTimestamp / NANOS_PER_MILLISECOND
} else {
rawTimestamp
}
val values = valueIndexes.mapNotNull { fields.getOrNull(it)?.toDoubleOrNull() }
return values.takeIf { it.size == valueIndexes.size }?.let { SensorSeriesSample(timestamp, it) }
}

private fun readSensorTimeAnchor(directory: DocumentFile): SensorTimeAnchor? {
val json = readMetadata(directory)
val unixMillis = json?.get("millis")?.asPrimitive()?.longOrNull
val elapsedRealtimeNanos = json?.get("nanos")?.asPrimitive()?.longOrNull
return if (unixMillis != null && elapsedRealtimeNanos != null) {
SensorTimeAnchor(unixMillis, elapsedRealtimeNanos)
} else {
null
}
}

private fun readMetadata(directory: DocumentFile): JsonObject? = directory.findFile(METADATA_FILE)
?.takeIf(DocumentFile::isFile)
?.let(::readDocumentText)
?.takeIf(String::isNotBlank)
?.let(::parseMetadataObject)

private fun parseGpsCoordinates(document: DocumentFile): MeasurementFileContent.GpsCoordinates {
val coordinates = mutableListOf<GpsCoordinate>()
var totalCoordinates = 0
Expand DownExpand Up@@ -245,7 +285,10 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva
const val MAX_TEXT_CHARACTERS = 100_000
const val TEXT_BUFFER_SIZE = 4_096
const val SAMPLE_REPLACEMENT_INTERVAL = 100
const val NANOS_PER_MILLISECOND = 1_000_000L
val NON_VALUE_COLUMNS = setOf("t_sensor", "accuracy", "provider")
val JSON = Json { ignoreUnknownKeys = true }
}

private data class SensorTimeAnchor(val unixMillis: Long, val elapsedRealtimeNanos: Long)
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,9 @@ package com.tomasrepcik.sensorbox.domain.paired

import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger
import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent
import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionEvent
import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionStore
import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementStopReason
import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementStopped
import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All@@ -27,13 +27,11 @@ class PhoneRecordingSessionObserver @Inject constructor(
fun start() {
if (!started.compareAndSet(false, true)) return
scope.launch {
sessionStore.events.collect { event ->
if (event is MeasurementSessionEvent.Stopped) onStopped(event)
}
sessionStore.events.collect(::onStopped)
}
}

private suspend fun onStopped(event: MeasurementSessionEvent.Stopped) {
private suspend fun onStopped(event: MeasurementStopped) {
event.result.errorOrNull()?.let { error -> diagnosticLogger.record(error.toDiagnosticEvent()) }
val reason = event.reason.toAutomaticWearReason() ?: return
pairedRecordingCoordinator.onAutomaticPhoneStop(event.sessionId, reason)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,12 +261,13 @@ internal fun rememberGpsDetails(
DisposableEffect(gpsHandler, hasPermission, intervalSeconds, minimumDistanceMeters) {
var active = true
if (hasPermission) {
gpsHandler.configure(intervalSeconds, minimumDistanceMeters)
gpsHandler.addCallback(
GpsDetailsCallback(
onLocation = { if (active && it != null) location = it },
onAvailability = { if (active) isAvailable = it },
),
intervalSeconds = intervalSeconds,
minDistanceMeters = minimumDistanceMeters,
)
}
onDispose {
Expand Down
Loading