From 433ccaa31106c9e1bc5e48a55b84477b241b4bc5 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Sun, 23 Aug 2026 23:18:07 +0200 Subject: [PATCH 1/2] refactor product architecture and wear protocol --- README.md | 2 +- .../motionapps/wearoslib/WearOsConstants.kt | 4 +- .../wearoslib/protocol/WearCommand.kt | 2 +- .../wearoslib/protocol/WearCommandCodec.kt | 11 ++-- .../wearoslib/files/WearFilePathCodecTest.kt | 2 +- .../protocol/WearCommandCodecTest.kt | 18 +++++- .../PhoneSensorRecordingEmulatorTest.kt | 3 +- .../main/MeasurementSetupScreenRobot.kt | 2 +- .../presentation/main/RecordScreenRobot.kt | 2 +- app/src/main/AndroidManifest.xml | 13 +---- .../MeasurementPermissionUseCase.kt | 31 +++------- .../domain/measurement/MeasurementRequest.kt | 36 +++++++++++- .../measurement/PhoneRecordingController.kt | 40 ++----------- .../measurement/RecordingWorkflowGateway.kt | 6 +- .../paired/PairedRecordingCoordinator.kt | 5 +- .../sensors/GetAvailableSensorsUseCase.kt | 46 ++++++++------- .../domain/sensors/SensorDescriptor.kt | 11 +++- .../main/ActiveMeasurementScreen.kt | 10 +--- .../presentation/main/PrivacyScreen.kt | 3 - .../presentation/main/RecordingContract.kt | 25 +++++++- .../presentation/main/RecordingViewModel.kt | 31 +--------- .../presentation/main/SensorDetailsScreen.kt | 18 +++--- .../presentation/main/SensorIconResource.kt | 1 - .../presentation/main/SensorPreviewScreen.kt | 9 +-- app/src/main/res/drawable/ic_heart_rate.xml | 16 ------ app/src/main/res/values/strings.xml | 3 - .../MeasurementPermissionUseCaseTest.kt | 40 +++++++++++++ .../measurement/MeasurementRequestTest.kt | 57 +++++++++++++++++++ .../domain/sensors/PhoneSensorTypesTest.kt | 12 ++++ .../domain/sensors/SensorReportingModeTest.kt | 16 ++++++ .../presentation/main/FeatureViewModelTest.kt | 2 +- .../main/RecordingStateConversionTest.kt | 37 ++++++++++++ .../main/RecordingStateFixtures.kt | 2 +- .../sensorbox/core/error/FileDiagnostics.kt | 9 +-- .../sensorbox/core/format/ValueFormats.kt | 20 +++++++ .../sensorbox/core/time/ClockFormats.kt | 48 ++++++++++++++++ .../sensorbox/core/format/ValueFormatsTest.kt | 30 ++++++++++ .../sensorbox/core/time/ClockFormatsTest.kt | 22 +++++++ sensorservices/src/main/AndroidManifest.xml | 5 -- .../handlers/measurements/ExtraInfoHandler.kt | 7 +-- .../intent/MeasurementIntentFactory.kt | 7 +-- .../sensorservices/types/SensorSpec.kt | 1 - wear/src/main/AndroidManifest.xml | 16 +----- .../communication/WearCommandEnvironment.kt | 6 +- .../WearMeasurementPermissionUseCase.kt | 22 +++---- .../domain/sensors/GetWearSensorsUseCase.kt | 38 ++++++------- .../domain/sensors/WearSensorDescriptor.kt | 2 +- .../dashboard/WearDashboardScreen.kt | 3 +- .../dashboard/WearDashboardViewModel.kt | 5 +- .../WearMeasurementPermissionUseCaseTest.kt | 15 +++++ .../domain/sensors/WearSensorTypesTest.kt | 12 ++++ 51 files changed, 509 insertions(+), 275 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_heart_rate.xml create mode 100644 app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementPermissionUseCaseTest.kt create mode 100644 app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequestTest.kt create mode 100644 app/src/test/java/com/motionapps/sensorbox/domain/sensors/PhoneSensorTypesTest.kt create mode 100644 app/src/test/java/com/motionapps/sensorbox/domain/sensors/SensorReportingModeTest.kt create mode 100644 app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateConversionTest.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/format/ValueFormats.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/time/ClockFormats.kt create mode 100644 core/src/test/java/com/motionapps/sensorbox/core/format/ValueFormatsTest.kt create mode 100644 core/src/test/java/com/motionapps/sensorbox/core/time/ClockFormatsTest.kt create mode 100644 wear/src/test/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementPermissionUseCaseTest.kt create mode 100644 wear/src/test/java/com/motionapps/sensorbox/domain/sensors/WearSensorTypesTest.kt diff --git a/README.md b/README.md index 9629b45..846400d 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Modules: - `recording-core`: pure Kotlin recording state machine, source roles, scheduling, and cleanup policy. - `core`: Android DataStore preferences, document storage, local rotating diagnostics, and reusable test fixtures. - `sensorservices`: Android recording adapters, foreground host, and linear sensor/GPS writers. It has no Wear dependency. -- `WearOsLib`: coroutine-based connectivity, strict protocol v2 command encoding, and Channel file transport. App policy stays in `app` and `wear`. +- `WearOsLib`: coroutine-based connectivity, strict protocol v3 command encoding, and Channel file transport. App policy stays in `app` and `wear`. Paired phone/watch recording is all-or-nothing: both sides prepare before either commits, commands are session-correlated and idempotent, timeouts use bounded retries, and rejection or timeout compensates both sides. diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt index 283f93a..a00054e 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt @@ -2,7 +2,7 @@ package com.motionapps.wearoslib object WearOsConstants { const val PHONE_APP_CAPABILITY = "phone_app" - const val PHONE_MESSAGE_PATH = "/sensorbox/v2/phone" + const val PHONE_MESSAGE_PATH = "/sensorbox/v3/phone" const val WEAR_APP_CAPABILITY = "wear_app" - const val WEAR_MESSAGE_PATH = "/sensorbox/v2/wear" + const val WEAR_MESSAGE_PATH = "/sensorbox/v3/wear" } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt index 4fc3633..16686b0 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt @@ -58,4 +58,4 @@ enum class WearStopReason { SERVICE_DESTROYED, } -data class WearSensorInfo(val type: Int, val name: String, val vendor: String, val isHeartRate: Boolean) +data class WearSensorInfo(val type: Int, val name: String, val vendor: String) diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt index 0415be4..b497271 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt @@ -11,7 +11,7 @@ import java.io.DataInputStream import java.io.DataOutputStream object WearCommandCodec { - const val PROTOCOL_VERSION = 2 + const val PROTOCOL_VERSION = 3 fun encode(command: WearCommand): AppResult = validate(command).flatMap { appResult(AppErrorCode.CONNECTIVITY, "Encode Wear command") { @@ -28,7 +28,7 @@ object WearCommandCodec { fun decode(payload: ByteArray): AppResult = appResult( AppErrorCode.CONNECTIVITY, - "Decode Wear protocol v2 command", + "Decode Wear protocol v3 command", ) { DataInputStream(ByteArrayInputStream(payload)).use { input -> require(input.readInt() == MAGIC) { "Unsupported Wear protocol magic" } @@ -40,7 +40,7 @@ object WearCommandCodec { private fun validate(command: WearCommand): AppResult = if (command.isValid()) { AppResult.success(Unit) } else { - AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear protocol v2 command")) + AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear protocol v3 command")) } private fun DataOutputStream.writeCommand(command: WearCommand) { @@ -104,7 +104,6 @@ object WearCommandCodec { writeInt(sensor.type) writeUTF(sensor.name) writeUTF(sensor.vendor) - writeBoolean(sensor.isHeartRate) } } @@ -152,11 +151,11 @@ object WearCommandCodec { val count = readUnsignedByte() require(count <= MAX_SENSORS) { "Too many Wear sensors" } return WearCommand.SensorList( - List(count) { WearSensorInfo(readInt(), readUTF(), readUTF(), readBoolean()) }, + List(count) { WearSensorInfo(readInt(), readUTF(), readUTF()) }, ) } - private const val MAGIC = 0x53425832 + private const val MAGIC = 0x53425833 private const val TYPE_LAUNCH_PHONE = 1 private const val TYPE_SYNC_MEASUREMENTS = 2 private const val TYPE_REQUEST_SENSOR_LIST = 3 diff --git a/WearOsLib/src/test/java/com/motionapps/wearoslib/files/WearFilePathCodecTest.kt b/WearOsLib/src/test/java/com/motionapps/wearoslib/files/WearFilePathCodecTest.kt index 2b35f59..a060aef 100644 --- a/WearOsLib/src/test/java/com/motionapps/wearoslib/files/WearFilePathCodecTest.kt +++ b/WearOsLib/src/test/java/com/motionapps/wearoslib/files/WearFilePathCodecTest.kt @@ -7,7 +7,7 @@ import org.junit.Test class WearFilePathCodecTest { @Test fun `Given safe metadata When encoded and decoded Then names survive`() { - val given = WearFileMetadata("recording_2026-08-13_12-30-00", "heart_rate.csv") + val given = WearFileMetadata("recording_2026-08-13_12-30-00", "accelerometer.csv") val actual = WearFilePathCodec.decode(WearFilePathCodec.encode(given).getOrThrow()) diff --git a/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt index 7c807d3..fd9b075 100644 --- a/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt +++ b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt @@ -7,7 +7,7 @@ import org.junit.Test class WearCommandCodecTest { @Test - fun `Given protocol v2 commands When round tripped Then every field survives`() { + fun `Given protocol v3 commands When round tripped Then every field survives`() { val request = WearRecordingRequest( folderName = "shared_session", sensorIds = listOf(1, 4, 21), @@ -19,7 +19,7 @@ class WearCommandCodecTest { WearCommand.LaunchPhone, WearCommand.SyncMeasurements, WearCommand.RequestSensorList, - WearCommand.SensorList(listOf(WearSensorInfo(21, "Heart rate", "Fixture", isHeartRate = true))), + WearCommand.SensorList(listOf(WearSensorInfo(1, "Accelerometer", "Fixture"))), WearCommand.PrepareRecording("session-123", request), WearCommand.CommitRecording("session-123", 1_800_000_000_000L), WearCommand.AbortRecording("session-123"), @@ -51,6 +51,13 @@ class WearCommandCodecTest { assertTrue(WearCommandCodec.decode(v1Payload).isFailure) } + @Test + fun `Given a protocol v2 header When decoded Then it is rejected`() { + val v2Payload = byteArrayOf(0x53, 0x42, 0x58, 0x32, 0x02, 0x01) + + assertTrue(WearCommandCodec.decode(v2Payload).isFailure) + } + @Test fun `Given trailing bytes When decoded Then payload is rejected`() { val valid = WearCommandCodec.encode(WearCommand.LaunchPhone).getOrThrow() @@ -58,6 +65,13 @@ class WearCommandCodecTest { assertTrue(WearCommandCodec.decode(valid + byteArrayOf(99)).isFailure) } + @Test + fun `Given a truncated v3 payload When decoded Then it is rejected`() { + val malformed = byteArrayOf(0x53, 0x42, 0x58, 0x33, 0x03) + + assertTrue(WearCommandCodec.decode(malformed).isFailure) + } + @Test fun `Given a successful acknowledgement with an error When encoded Then it is rejected`() { val invalid = WearCommand.Acknowledgement( diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt b/app/src/androidTest/java/com/motionapps/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt index 0f03577..11a516e 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt @@ -6,6 +6,7 @@ import android.hardware.Sensor import android.hardware.SensorManager import android.util.Log import androidx.core.content.ContextCompat +import com.motionapps.sensorbox.core.time.SystemEpochClock import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.motionapps.sensorservices.intent.MeasurementIntentFactory @@ -51,7 +52,7 @@ class PhoneSensorRecordingEmulatorTest { private fun hasAccelerometer(): Boolean = context.getSystemService(SensorManager::class.java) .getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null - private fun recordingIntent(): Intent = MeasurementIntentFactory(context).create( + private fun recordingIntent(): Intent = MeasurementIntentFactory(context, SystemEpochClock).create( MeasurementLaunchRequest( folderName = MEASUREMENT_NAME, useInternalStorage = true, diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt index 89cc619..5ad7952 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt @@ -13,7 +13,7 @@ class MeasurementSetupScreenRobot(private val rule: ComposeContentTestRule) { SensorBoxTheme { MeasurementSetupScreen( state = RecordingState( - sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture", false)), + sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture")), selectedSensorIds = setOf(1), storagePath = "Fixture/SensorBox", ), diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt index 342f7ed..025393d 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt @@ -18,7 +18,7 @@ class RecordScreenRobot(private val rule: ComposeContentTestRule) { SensorBoxTheme { RecordScreen( state = RecordingState( - sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture", false)), + sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture")), selectedSensorIds = if (selected) setOf(1) else emptySet(), includesGps = gpsSelected, storagePath = "Fixture/SensorBox", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d3d085b..f32032c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,17 +43,6 @@ - - - - - - - @@ -61,7 +50,7 @@ diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementPermissionUseCase.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementPermissionUseCase.kt index 3bb55c1..20151a1 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementPermissionUseCase.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementPermissionUseCase.kt @@ -3,36 +3,21 @@ package com.motionapps.sensorbox.domain.measurement import android.Manifest import android.content.Context import android.content.pm.PackageManager -import android.health.connect.HealthPermissions import android.os.Build import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class MeasurementPermissionUseCase @Inject constructor(@ApplicationContext private val context: Context) { - fun missingPermissions(request: MeasurementRequest, includesHeartRate: Boolean): Set = buildSet { - if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS) - if (request.includesGps) add(Manifest.permission.ACCESS_FINE_LOCATION) - if (request.activityRecognition && Build.VERSION.SDK_INT >= 29) add(Manifest.permission.ACTIVITY_RECOGNITION) - if (includesHeartRate) addHeartRatePermissions() - }.filterNot(::isGranted).toSet() - - private fun MutableSet.addHeartRatePermissions() { - when { - Build.VERSION.SDK_INT >= 36 -> { - add(HealthPermissions.READ_HEART_RATE) - add(HealthPermissions.READ_HEALTH_DATA_IN_BACKGROUND) - } - - Build.VERSION.SDK_INT >= 33 -> { - add(Manifest.permission.BODY_SENSORS) - add(Manifest.permission.BODY_SENSORS_BACKGROUND) - } - - else -> add(Manifest.permission.BODY_SENSORS) - } - } + fun missingPermissions(request: MeasurementRequest): Set = + requiredMeasurementPermissions(request, Build.VERSION.SDK_INT).filterNot(::isGranted).toSet() private fun isGranted(permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } + +internal fun requiredMeasurementPermissions(request: MeasurementRequest, sdkInt: Int): Set = buildSet { + if (sdkInt >= 33) add(Manifest.permission.POST_NOTIFICATIONS) + if (request.includesGps) add(Manifest.permission.ACCESS_FINE_LOCATION) + if (request.activityRecognition && sdkInt >= 29) add(Manifest.permission.ACTIVITY_RECOGNITION) +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequest.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequest.kt index f0f2520..7786ee5 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequest.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequest.kt @@ -1,5 +1,8 @@ package com.motionapps.sensorbox.domain.measurement +import android.hardware.SensorManager +import com.motionapps.sensorservices.intent.MeasurementLaunchRequest + data class MeasurementRequest( val sensorIds: Set, val includesGps: Boolean, @@ -19,4 +22,35 @@ data class MeasurementRequest( val activityRecognition: Boolean = false, val activityRecognitionPeriodSeconds: Int = 30, val significantMotion: Boolean = false, -) +) { + fun toLaunchRequest(sessionId: String, folderName: String) = MeasurementLaunchRequest( + sessionId = sessionId, + folderName = folderName, + useInternalStorage = false, + sensorIds = sensorIds, + sensorSamplingPeriod = SENSOR_PERIODS.getOrElse(samplingPeriodIndex) { + SensorManager.SENSOR_DELAY_FASTEST + }, + includesGps = includesGps, + stopOnLowBattery = stopOnLowBattery, + useWakeLock = useWakeLock, + gpsIntervalSeconds = gpsIntervalSeconds, + gpsMinDistanceMeters = gpsMinDistanceMeters, + measurementType = measurementType, + durationMillis = durationSeconds.coerceAtLeast(0) * 1_000L, + notes = notes, + alarmOffsetsSeconds = alarmOffsetsSeconds, + activityRecognition = activityRecognition, + activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, + significantMotion = significantMotion, + ) + + private companion object { + val SENSOR_PERIODS = intArrayOf( + SensorManager.SENSOR_DELAY_FASTEST, + SensorManager.SENSOR_DELAY_GAME, + SensorManager.SENSOR_DELAY_UI, + SensorManager.SENSOR_DELAY_NORMAL, + ) + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt index d4e4640..dfc01a8 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt @@ -2,7 +2,6 @@ package com.motionapps.sensorbox.domain.measurement import android.content.Context import android.content.Intent -import android.hardware.SensorManager import androidx.core.content.ContextCompat import com.motionapps.sensorbox.core.error.AppError import com.motionapps.sensorbox.core.error.AppErrorCode @@ -52,7 +51,10 @@ class AndroidPhoneRecordingController @Inject constructor( AppResult.success( PreparedPhoneRecording( sessionId = sessionId, - launchRequest = request.toLaunchRequest(sessionId), + launchRequest = request.toLaunchRequest( + sessionId, + intentFactory.newFolderName(request.customName, request.measurementType), + ), ), ) } @@ -95,38 +97,4 @@ class AndroidPhoneRecordingController @Inject constructor( .setAction(MeasurementService.ACTION_STOP) context.startService(stopIntent) } - - private fun MeasurementRequest.toLaunchRequest(sessionId: String): MeasurementLaunchRequest = - MeasurementLaunchRequest( - sessionId = sessionId, - folderName = intentFactory.newFolderName(customName, measurementType), - useInternalStorage = false, - sensorIds = sensorIds, - sensorSamplingPeriod = samplingPeriod(samplingPeriodIndex), - includesGps = includesGps, - stopOnLowBattery = stopOnLowBattery, - useWakeLock = useWakeLock, - gpsIntervalSeconds = gpsIntervalSeconds, - gpsMinDistanceMeters = gpsMinDistanceMeters, - measurementType = measurementType, - durationMillis = durationSeconds.coerceAtLeast(0) * 1_000L, - notes = notes, - alarmOffsetsSeconds = alarmOffsetsSeconds, - activityRecognition = activityRecognition, - activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, - significantMotion = significantMotion, - ) - - private fun samplingPeriod(index: Int): Int = SENSOR_PERIODS.getOrElse(index) { - SensorManager.SENSOR_DELAY_FASTEST - } - - private companion object { - val SENSOR_PERIODS = intArrayOf( - SensorManager.SENSOR_DELAY_FASTEST, - SensorManager.SENSOR_DELAY_GAME, - SensorManager.SENSOR_DELAY_UI, - SensorManager.SENSOR_DELAY_NORMAL, - ) - } } diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/RecordingWorkflowGateway.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/RecordingWorkflowGateway.kt index bf29acf..8b1864e 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/RecordingWorkflowGateway.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/RecordingWorkflowGateway.kt @@ -17,7 +17,7 @@ interface RecordingWorkflowGateway { fun persistStorage(resultIntent: Intent?): AppResult - fun missingPermissions(request: MeasurementRequest, includesHeartRate: Boolean): Set + fun missingPermissions(request: MeasurementRequest): Set suspend fun start(request: MeasurementRequest): AppResult @@ -42,8 +42,8 @@ class AndroidRecordingWorkflowGateway @Inject constructor( ?.let(documentStorage::persist) ?: AppResult.failure(AppError(AppErrorCode.STORAGE, "Select recording storage directory")) - override fun missingPermissions(request: MeasurementRequest, includesHeartRate: Boolean): Set = - measurementPermissions.missingPermissions(request, includesHeartRate) + override fun missingPermissions(request: MeasurementRequest): Set = + measurementPermissions.missingPermissions(request) override suspend fun start(request: MeasurementRequest): AppResult = measurementControl.start(request) diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt index df1bc93..13cea41 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt @@ -15,6 +15,7 @@ import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH import com.motionapps.wearoslib.protocol.SendWearCommandUseCase import com.motionapps.wearoslib.protocol.WearAcknowledgementOutcome import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearCommandCodec import com.motionapps.wearoslib.protocol.WearRecordingRequest import com.motionapps.wearoslib.protocol.WearSessionCommand import com.motionapps.wearoslib.protocol.WearStopReason @@ -184,7 +185,7 @@ class PairedRecordingCoordinator @Inject constructor( context = mapOf( "sessionId" to sessionId, "retryCount" to RETRY_COUNT.toString(), - "protocolVersion" to "2", + "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), ), isRetryable = true, ), @@ -221,7 +222,7 @@ class PairedRecordingCoordinator @Inject constructor( "sessionId" to sessionId, "retryCount" to retryCount.toString(), "failureCount" to failureCount.toString(), - "protocolVersion" to "2", + "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), ), ), ) diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt b/app/src/main/java/com/motionapps/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt index a33bca8..d38e684 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt @@ -11,7 +11,7 @@ class GetAvailableSensorsUseCase @Inject constructor(@ApplicationContext context operator fun invoke(): List = sensorManager .getSensorList(Sensor.TYPE_ALL) - .filter { it.type in SUPPORTED_SENSOR_TYPES } + .filter { it.type in PHONE_SENSOR_TYPES } .distinctBy(Sensor::getType) .map { it.toDescriptor() } .sortedBy(SensorDescriptor::name) @@ -20,7 +20,6 @@ class GetAvailableSensorsUseCase @Inject constructor(@ApplicationContext context type = type, name = name, vendor = vendor, - isHeartRate = type == Sensor.TYPE_HEART_RATE, version = version, stringType = stringType, maximumRange = maximumRange, @@ -28,26 +27,31 @@ class GetAvailableSensorsUseCase @Inject constructor(@ApplicationContext context power = power, minimumDelayMicros = minDelay, maximumDelayMicros = maxDelay, - reportingMode = reportingMode, + reportingMode = reportingMode.toSensorReportingMode(), isWakeUpSensor = isWakeUpSensor, ) +} + +internal val PHONE_SENSOR_TYPES = setOf( + Sensor.TYPE_ACCELEROMETER, + Sensor.TYPE_AMBIENT_TEMPERATURE, + Sensor.TYPE_GRAVITY, + Sensor.TYPE_GYROSCOPE, + Sensor.TYPE_LIGHT, + Sensor.TYPE_LINEAR_ACCELERATION, + Sensor.TYPE_MAGNETIC_FIELD, + Sensor.TYPE_PRESSURE, + Sensor.TYPE_PROXIMITY, + Sensor.TYPE_RELATIVE_HUMIDITY, + Sensor.TYPE_ROTATION_VECTOR, + Sensor.TYPE_STEP_COUNTER, + Sensor.TYPE_STEP_DETECTOR, +) - private companion object { - val SUPPORTED_SENSOR_TYPES = setOf( - Sensor.TYPE_ACCELEROMETER, - Sensor.TYPE_AMBIENT_TEMPERATURE, - Sensor.TYPE_GRAVITY, - Sensor.TYPE_GYROSCOPE, - Sensor.TYPE_HEART_RATE, - Sensor.TYPE_LIGHT, - Sensor.TYPE_LINEAR_ACCELERATION, - Sensor.TYPE_MAGNETIC_FIELD, - Sensor.TYPE_PRESSURE, - Sensor.TYPE_PROXIMITY, - Sensor.TYPE_RELATIVE_HUMIDITY, - Sensor.TYPE_ROTATION_VECTOR, - Sensor.TYPE_STEP_COUNTER, - Sensor.TYPE_STEP_DETECTOR, - ) - } +internal fun Int.toSensorReportingMode(): SensorReportingMode = when (this) { + Sensor.REPORTING_MODE_CONTINUOUS -> SensorReportingMode.CONTINUOUS + Sensor.REPORTING_MODE_ON_CHANGE -> SensorReportingMode.ON_CHANGE + Sensor.REPORTING_MODE_ONE_SHOT -> SensorReportingMode.ONE_SHOT + Sensor.REPORTING_MODE_SPECIAL_TRIGGER -> SensorReportingMode.SPECIAL_TRIGGER + else -> SensorReportingMode.UNKNOWN } diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/sensors/SensorDescriptor.kt b/app/src/main/java/com/motionapps/sensorbox/domain/sensors/SensorDescriptor.kt index 923a29c..65e5550 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/sensors/SensorDescriptor.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/sensors/SensorDescriptor.kt @@ -4,7 +4,6 @@ data class SensorDescriptor( val type: Int, val name: String, val vendor: String, - val isHeartRate: Boolean, val version: Int = 0, val stringType: String = "", val maximumRange: Float = 0f, @@ -12,6 +11,14 @@ data class SensorDescriptor( val power: Float = 0f, val minimumDelayMicros: Int = 0, val maximumDelayMicros: Int = 0, - val reportingMode: Int = 0, + val reportingMode: SensorReportingMode = SensorReportingMode.UNKNOWN, val isWakeUpSensor: Boolean = false, ) + +enum class SensorReportingMode { + CONTINUOUS, + ON_CHANGE, + ONE_SHOT, + SPECIAL_TRIGGER, + UNKNOWN, +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt index dee1240..09bc8a4 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.R +import com.motionapps.sensorbox.core.format.ValueFormats import com.motionapps.sensorbox.ui.theme.SensorBoxRecording import com.motionapps.sensorservices.session.MeasurementSessionState @@ -162,7 +163,7 @@ private fun MeasurementTimer(elapsedSeconds: Long, folderName: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(R.string.elapsed_time), color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(10.dp)) - Text(formatElapsed(elapsedSeconds), style = MaterialTheme.typography.displayLarge) + Text(ValueFormats.elapsedSeconds(elapsedSeconds), style = MaterialTheme.typography.displayLarge) Text(folderName, color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center) } } @@ -251,13 +252,6 @@ private fun SummaryValue(value: String, label: String) { } } -private fun formatElapsed(seconds: Long): String { - val hours = seconds / 3_600 - val minutes = seconds % 3_600 / 60 - val remainingSeconds = seconds % 60 - return "%02d:%02d:%02d".format(hours, minutes, remainingSeconds) -} - private const val RECORDING_PULSE_MILLIS = 850 private const val RECORDING_DIM_ALPHA = 0.28f private const val RECORDING_MIN_SCALE = 0.78f diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/PrivacyScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/PrivacyScreen.kt index 78fb643..dddb890 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/PrivacyScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/PrivacyScreen.kt @@ -29,9 +29,6 @@ fun PrivacyScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { ) } item { Text(stringResource(R.string.privacy_storage_body)) } - item { - Text(stringResource(R.string.privacy_heart_rate_body)) - } item { Text(stringResource(R.string.privacy_no_upload_body)) } } } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt index 932411a..2ca06f5 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt @@ -2,6 +2,7 @@ package com.motionapps.sensorbox.presentation.main import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.domain.measurement.MeasurementRequest import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import com.motionapps.sensorservices.session.MeasurementSessionState @@ -37,7 +38,29 @@ data class RecordingState( val isWearConnected: Boolean = false, val message: RecordingMessage = RecordingMessage.NONE, val errorCode: AppErrorCode? = null, -) +) { + fun toMeasurementRequest() = MeasurementRequest( + sensorIds = selectedSensorIds, + includesGps = includesGps, + samplingPeriodIndex = preferences.recording.sensorSamplingPeriod, + stopOnLowBattery = preferences.recording.restrictMeasurementOnLowBattery, + useWakeLock = preferences.recording.useWakeLock, + gpsIntervalSeconds = preferences.recording.gpsIntervalSeconds, + gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, + wearSensorIds = selectedWearSensorIds, + wearIncludesGps = wearIncludesGps, + customName = customMeasurementName, + measurementType = measurementType, + delaySeconds = startDelaySeconds, + durationSeconds = if (measurementType == "TIMED") durationSeconds.coerceAtLeast(1) else 0, + notes = notes.lines().map(String::trim).filter(String::isNotEmpty), + alarmOffsetsSeconds = alarmOffsets.split(',', ';', ' ') + .mapNotNull(String::toIntOrNull).filter { it >= 0 }, + activityRecognition = activityRecognition, + activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, + significantMotion = significantMotion, + ) +} sealed interface RecordingIntent { data class Navigate(val route: MainRoute) : RecordingIntent diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt index 4c5af3c..7868d15 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt @@ -101,7 +101,7 @@ class RecordingViewModel @Inject constructor( fun handlePermissionResult() { val request = state.value.toMeasurementRequest() - val missing = workflow.missingPermissions(request, state.value.includesHeartRate()) + val missing = workflow.missingPermissions(request) if (missing.isEmpty()) { startMeasurement() } else { @@ -188,7 +188,6 @@ class RecordingViewModel @Inject constructor( type = sensor.type, name = sensor.name, vendor = sensor.vendor, - isHeartRate = sensor.isHeartRate, ) }, ) @@ -223,7 +222,7 @@ class RecordingViewModel @Inject constructor( } private fun requestMissingPermissions(request: MeasurementRequest): Set? { - val permissions = workflow.missingPermissions(request, state.value.includesHeartRate()) + val permissions = workflow.missingPermissions(request) return permissions.takeIf(Set::isNotEmpty) } @@ -257,32 +256,6 @@ class RecordingViewModel @Inject constructor( ) } - private fun RecordingState.toMeasurementRequest() = MeasurementRequest( - sensorIds = selectedSensorIds, - includesGps = includesGps, - samplingPeriodIndex = preferences.recording.sensorSamplingPeriod, - stopOnLowBattery = preferences.recording.restrictMeasurementOnLowBattery, - useWakeLock = preferences.recording.useWakeLock, - gpsIntervalSeconds = preferences.recording.gpsIntervalSeconds, - gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, - wearSensorIds = selectedWearSensorIds, - wearIncludesGps = wearIncludesGps, - customName = customMeasurementName, - measurementType = measurementType, - delaySeconds = startDelaySeconds, - durationSeconds = if (measurementType == "TIMED") durationSeconds.coerceAtLeast(1) else 0, - notes = notes.lines().map(String::trim).filter(String::isNotEmpty), - alarmOffsetsSeconds = alarmOffsets.split(',', ';', ' ') - .mapNotNull(String::toIntOrNull).filter { it >= 0 }, - activityRecognition = activityRecognition, - activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, - significantMotion = significantMotion, - ) - - private fun RecordingState.includesHeartRate(): Boolean = sensors.any { - it.isHeartRate && it.type in selectedSensorIds - } - private fun MeasurementRequest.hasAnySource(): Boolean = sensorIds.isNotEmpty() || includesGps || wearSensorIds.isNotEmpty() || wearIncludesGps || activityRecognition || significantMotion } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt index 6ac1d2f..7ac1d7b 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.google.android.gms.location.LocationAvailability import com.motionapps.sensorbox.R +import com.motionapps.sensorbox.core.format.ValueFormats import com.motionapps.sensorbox.domain.sensors.SensorDescriptor +import com.motionapps.sensorbox.domain.sensors.SensorReportingMode import com.motionapps.sensorservices.handlers.GPSHandler @Composable @@ -234,12 +236,12 @@ internal fun DetailRow(label: String, value: String) { } @Composable -private fun reportingModeLabel(mode: Int): String = when (mode) { - 0 -> stringResource(R.string.reporting_continuous) - 1 -> stringResource(R.string.reporting_on_change) - 2 -> stringResource(R.string.reporting_one_shot) - 3 -> stringResource(R.string.reporting_special_trigger) - else -> stringResource(R.string.unknown_with_value, mode) +private fun reportingModeLabel(mode: SensorReportingMode): String = when (mode) { + SensorReportingMode.CONTINUOUS -> stringResource(R.string.reporting_continuous) + SensorReportingMode.ON_CHANGE -> stringResource(R.string.reporting_on_change) + SensorReportingMode.ONE_SHOT -> stringResource(R.string.reporting_one_shot) + SensorReportingMode.SPECIAL_TRIGGER -> stringResource(R.string.reporting_special_trigger) + SensorReportingMode.UNKNOWN -> stringResource(R.string.unknown) } @Composable @@ -263,12 +265,10 @@ internal fun sensorUnit(type: Int): String = when (type) { Sensor.TYPE_STEP_COUNTER, Sensor.TYPE_STEP_DETECTOR -> stringResource(R.string.unit_steps) - Sensor.TYPE_HEART_RATE -> stringResource(R.string.unit_heart_rate) - else -> stringResource(R.string.unit_none) } -internal fun formatDecimal(value: Number): String = "%.2f".format(value.toDouble()) +internal fun formatDecimal(value: Number): String = ValueFormats.decimal(value) @Composable private fun locationAvailabilityLabel(isAvailable: Boolean?): String = when (isAvailable) { diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorIconResource.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorIconResource.kt index 14709b0..a3c30f5 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorIconResource.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorIconResource.kt @@ -25,5 +25,4 @@ private val SENSOR_ICON_RESOURCES = mapOf( Sensor.TYPE_AMBIENT_TEMPERATURE to R.drawable.ic_temperature, Sensor.TYPE_STEP_COUNTER to R.drawable.ic_steps, Sensor.TYPE_STEP_DETECTOR to R.drawable.ic_steps_detector, - Sensor.TYPE_HEART_RATE to R.drawable.ic_heart_rate, ) diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt index a55e432..7908633 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.motionapps.sensorbox.R +import com.motionapps.sensorbox.core.format.ValueFormats import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import kotlin.math.max @@ -293,12 +294,12 @@ private fun DrawScope.drawSensorLines( private fun List.durationSeconds(): Float = if (size < 2) 0f else (last().timestampNanos - first().timestampNanos) / NANOS_PER_SECOND -private fun formatChartTime(seconds: Float): String = "%.1f".format(seconds) +private fun formatChartTime(seconds: Float): String = ValueFormats.decimal(seconds, fractionDigits = 1) private fun formatChartValue(value: Float): String = when { - kotlin.math.abs(value) >= 100f -> "%.0f".format(value) - kotlin.math.abs(value) >= 10f -> "%.1f".format(value) - else -> "%.2f".format(value) + kotlin.math.abs(value) >= 100f -> ValueFormats.decimal(value, fractionDigits = 0) + kotlin.math.abs(value) >= 10f -> ValueFormats.decimal(value, fractionDigits = 1) + else -> ValueFormats.decimal(value) } @Composable diff --git a/app/src/main/res/drawable/ic_heart_rate.xml b/app/src/main/res/drawable/ic_heart_rate.xml deleted file mode 100644 index 0f879f4..0000000 --- a/app/src/main/res/drawable/ic_heart_rate.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42222f1..c68636a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,7 +10,6 @@ On Off Unknown - Unknown (%1$d) Unavailable Waiting… Ready @@ -74,7 +73,6 @@ Why SensorBox requests access Sensor and location permissions are requested only after you select those sources and tap Start recording. Recordings are written to the folder you choose through Android’s system document picker. - Heart-rate access is used only when you explicitly select a heart-rate sensor. Background access keeps that user-started foreground recording alive while its notification is visible. SensorBox does not upload recorded sensor or location data. Pick sensors @@ -221,7 +219,6 @@ lx °C steps - bpm Diagnostics Errors are stored locally and shared only when you choose to export them. diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementPermissionUseCaseTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementPermissionUseCaseTest.kt new file mode 100644 index 0000000..e4f4e7f --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementPermissionUseCaseTest.kt @@ -0,0 +1,40 @@ +package com.motionapps.sensorbox.domain.measurement + +import android.Manifest +import org.junit.Assert.assertEquals +import org.junit.Test + +class MeasurementPermissionUseCaseTest { + @Test + fun `Given optional phone sources When permissions are derived Then only supported permissions are used`() { + val request = request(includesGps = true, activityRecognition = true) + + assertEquals( + setOf( + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACTIVITY_RECOGNITION, + ), + requiredMeasurementPermissions(request, sdkInt = 37), + ) + } + + @Test + fun `Given a legacy phone When permissions are derived Then version gated permissions are absent`() { + assertEquals( + setOf(Manifest.permission.ACCESS_FINE_LOCATION), + requiredMeasurementPermissions(request(includesGps = true, activityRecognition = true), sdkInt = 28), + ) + } + + private fun request(includesGps: Boolean, activityRecognition: Boolean) = MeasurementRequest( + sensorIds = emptySet(), + includesGps = includesGps, + samplingPeriodIndex = 0, + stopOnLowBattery = false, + useWakeLock = false, + gpsIntervalSeconds = 1, + gpsMinDistanceMeters = 0, + activityRecognition = activityRecognition, + ) +} diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequestTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequestTest.kt new file mode 100644 index 0000000..b00d559 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequestTest.kt @@ -0,0 +1,57 @@ +package com.motionapps.sensorbox.domain.measurement + +import android.hardware.SensorManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class MeasurementRequestTest { + @Test + fun `Given a measurement request When converted Then launch configuration is preserved`() { + val request = MeasurementRequest( + sensorIds = setOf(1, 4), + includesGps = true, + samplingPeriodIndex = 2, + stopOnLowBattery = true, + useWakeLock = true, + gpsIntervalSeconds = 12, + gpsMinDistanceMeters = 3, + measurementType = "TIMED", + durationSeconds = 45, + notes = listOf("note"), + alarmOffsetsSeconds = listOf(5), + activityRecognition = true, + activityRecognitionPeriodSeconds = 60, + significantMotion = true, + ) + + val launch = request.toLaunchRequest("session-1", "folder") + + assertEquals("session-1", launch.sessionId) + assertEquals("folder", launch.folderName) + assertEquals(setOf(1, 4), launch.sensorIds) + assertEquals(SensorManager.SENSOR_DELAY_UI, launch.sensorSamplingPeriod) + assertEquals(45_000L, launch.durationMillis) + assertEquals(listOf("note"), launch.notes) + assertEquals(listOf(5), launch.alarmOffsetsSeconds) + assertEquals(true, launch.activityRecognition) + assertEquals(true, launch.significantMotion) + } + + @Test + fun `Given an unknown sampling index When converted Then fastest is used`() { + val request = MeasurementRequest( + sensorIds = emptySet(), + includesGps = false, + samplingPeriodIndex = Int.MAX_VALUE, + stopOnLowBattery = false, + useWakeLock = false, + gpsIntervalSeconds = 1, + gpsMinDistanceMeters = 0, + ) + + assertEquals( + SensorManager.SENSOR_DELAY_FASTEST, + request.toLaunchRequest("session-1", "folder").sensorSamplingPeriod, + ) + } +} diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/sensors/PhoneSensorTypesTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/sensors/PhoneSensorTypesTest.kt new file mode 100644 index 0000000..8698b53 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/sensors/PhoneSensorTypesTest.kt @@ -0,0 +1,12 @@ +package com.motionapps.sensorbox.domain.sensors + +import android.hardware.Sensor +import org.junit.Assert.assertFalse +import org.junit.Test + +class PhoneSensorTypesTest { + @Test + fun `Given phone sensor discovery When supported types are inspected Then heart rate is absent`() { + assertFalse(Sensor.TYPE_HEART_RATE in PHONE_SENSOR_TYPES) + } +} diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/sensors/SensorReportingModeTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/sensors/SensorReportingModeTest.kt new file mode 100644 index 0000000..8db097a --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/sensors/SensorReportingModeTest.kt @@ -0,0 +1,16 @@ +package com.motionapps.sensorbox.domain.sensors + +import android.hardware.Sensor +import org.junit.Assert.assertEquals +import org.junit.Test + +class SensorReportingModeTest { + @Test + fun `Given Android reporting modes When mapped Then every value and unknown are represented`() { + assertEquals(SensorReportingMode.CONTINUOUS, Sensor.REPORTING_MODE_CONTINUOUS.toSensorReportingMode()) + assertEquals(SensorReportingMode.ON_CHANGE, Sensor.REPORTING_MODE_ON_CHANGE.toSensorReportingMode()) + assertEquals(SensorReportingMode.ONE_SHOT, Sensor.REPORTING_MODE_ONE_SHOT.toSensorReportingMode()) + assertEquals(SensorReportingMode.SPECIAL_TRIGGER, Sensor.REPORTING_MODE_SPECIAL_TRIGGER.toSensorReportingMode()) + assertEquals(SensorReportingMode.UNKNOWN, Int.MAX_VALUE.toSensorReportingMode()) + } +} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt index b6c363c..d151df8 100644 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt @@ -158,7 +158,7 @@ private class FakeRecordingWorkflow : RecordingWorkflowGateway { override fun persistStorage(resultIntent: Intent?): AppResult = AppResult.success(Unit) - override fun missingPermissions(request: MeasurementRequest, includesHeartRate: Boolean): Set = emptySet() + override fun missingPermissions(request: MeasurementRequest): Set = emptySet() override suspend fun start(request: MeasurementRequest): AppResult = startResult diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateConversionTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateConversionTest.kt new file mode 100644 index 0000000..af87341 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateConversionTest.kt @@ -0,0 +1,37 @@ +package com.motionapps.sensorbox.presentation.main + +import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.core.preferences.RecordingPreferences +import org.junit.Assert.assertEquals +import org.junit.Test + +class RecordingStateConversionTest { + @Test + fun `Given recording state input When converted Then request values are normalized`() { + val state = RecordingState( + selectedSensorIds = setOf(1), + includesGps = true, + customMeasurementName = "walk", + measurementType = "TIMED", + durationSeconds = 0, + notes = " first \n\n second ", + alarmOffsets = "10, bad; -2 30", + preferences = AppPreferences( + recording = RecordingPreferences( + sensorSamplingPeriod = 3, + gpsIntervalSeconds = 7, + gpsMinDistanceMeters = 2, + ), + ), + ) + + val request = state.toMeasurementRequest() + + assertEquals(setOf(1), request.sensorIds) + assertEquals(1, request.durationSeconds) + assertEquals(listOf("first", "second"), request.notes) + assertEquals(listOf(10, 30), request.alarmOffsetsSeconds) + assertEquals(3, request.samplingPeriodIndex) + assertEquals(7, request.gpsIntervalSeconds) + } +} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt index e79bd86..252791e 100644 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt @@ -5,7 +5,7 @@ import com.motionapps.sensorbox.domain.sensors.SensorDescriptor object RecordingStateFixtures { fun state(selectedSensorIds: Set = emptySet(), includesGps: Boolean = false) = RecordingState( sensors = listOf( - SensorDescriptor(type = 1, name = "Accelerometer", vendor = "Fixture", isHeartRate = false), + SensorDescriptor(type = 1, name = "Accelerometer", vendor = "Fixture"), ), selectedSensorIds = selectedSensorIds, includesGps = includesGps, diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt index def301b..e2a09fa 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt @@ -1,18 +1,16 @@ package com.motionapps.sensorbox.core.error import android.content.Context +import com.motionapps.sensorbox.core.time.ClockFormats import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorbox.core.time.SystemEpochClock import kotlinx.datetime.DatePeriod -import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.toLocalDateTime import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import kotlin.time.Instant data class DiagnosticMetadata( val appVersion: String, @@ -99,7 +97,7 @@ class FileDiagnostics internal constructor( } private fun DiagnosticEvent.toDiagnosticEntry(): String = buildString { - val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date(clock.nowMillis())) + val timestamp = ClockFormats.diagnosticTimestamp(clock.nowMillis()) append(timestamp).append(" | ").append(severity).append(" | ").append(code).append(" | ") .append(operation.safeText()).appendLine() append("message=").append(diagnosticMessage.safeText()).appendLine() @@ -166,7 +164,6 @@ class FileDiagnostics internal constructor( const val FILE_PREFIX = "sensorbox-diagnostics-" const val FILE_SUFFIX = ".txt" const val EXPORT_FILE_NAME = "sensorbox-diagnostics-export.txt" - const val TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" const val ENTRY_SEPARATOR = "---" const val NO_DIAGNOSTICS = "No diagnostics have been recorded.\n" const val MAX_ENTRY_CHARS = 32_000 diff --git a/core/src/main/java/com/motionapps/sensorbox/core/format/ValueFormats.kt b/core/src/main/java/com/motionapps/sensorbox/core/format/ValueFormats.kt new file mode 100644 index 0000000..96744ce --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/format/ValueFormats.kt @@ -0,0 +1,20 @@ +package com.motionapps.sensorbox.core.format + +import java.util.Locale + +object ValueFormats { + fun elapsedSeconds(totalSeconds: Long): String { + val safeSeconds = totalSeconds.coerceAtLeast(0) + val hours = safeSeconds / 3_600 + val minutes = safeSeconds % 3_600 / 60 + val seconds = safeSeconds % 60 + return String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, seconds) + } + + fun decimal(value: Number, fractionDigits: Int = 2): String { + require(fractionDigits in 0..MAX_FRACTION_DIGITS) + return String.format(Locale.ROOT, "%.${fractionDigits}f", value.toDouble()) + } + + private const val MAX_FRACTION_DIGITS = 6 +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/time/ClockFormats.kt b/core/src/main/java/com/motionapps/sensorbox/core/time/ClockFormats.kt new file mode 100644 index 0000000..c38ddf6 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/time/ClockFormats.kt @@ -0,0 +1,48 @@ +package com.motionapps.sensorbox.core.time + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +object ClockFormats { + fun folderTimestamp(epochMillis: Long, timeZone: TimeZone = TimeZone.currentSystemDefault()): String = + FOLDER_TIMESTAMP.format(localDateTime(epochMillis, timeZone)) + + fun metadataTimestamp(epochMillis: Long, timeZone: TimeZone = TimeZone.currentSystemDefault()): String = + METADATA_TIMESTAMP.format(localDateTime(epochMillis, timeZone)) + + fun diagnosticTimestamp(epochMillis: Long): String = Instant.fromEpochMilliseconds(epochMillis).toString() + + private fun localDateTime(epochMillis: Long, timeZone: TimeZone): LocalDateTime = + Instant.fromEpochMilliseconds(epochMillis).toLocalDateTime(timeZone) + + private val FOLDER_TIMESTAMP = LocalDateTime.Format { + year() + char('-') + monthNumber() + char('-') + day() + char('_') + hour() + char('-') + minute() + char('-') + second() + } + + private val METADATA_TIMESTAMP = LocalDateTime.Format { + day() + chars(". ") + monthNumber() + chars(". ") + year() + char(' ') + hour() + char(':') + minute() + char(':') + second() + } +} diff --git a/core/src/test/java/com/motionapps/sensorbox/core/format/ValueFormatsTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/format/ValueFormatsTest.kt new file mode 100644 index 0000000..a523ce0 --- /dev/null +++ b/core/src/test/java/com/motionapps/sensorbox/core/format/ValueFormatsTest.kt @@ -0,0 +1,30 @@ +package com.motionapps.sensorbox.core.format + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +class ValueFormatsTest { + @Test + fun `Given zero and hour boundaries When formatted Then elapsed components are padded`() { + assertEquals("00:00:00", ValueFormats.elapsedSeconds(0)) + assertEquals("00:59:59", ValueFormats.elapsedSeconds(3_599)) + assertEquals("01:00:00", ValueFormats.elapsedSeconds(3_600)) + } + + @Test + fun `Given a duration over one day When formatted Then hours do not wrap`() { + assertEquals("49:02:03", ValueFormats.elapsedSeconds(49 * 3_600L + 2 * 60L + 3)) + } + + @Test + fun `Given a comma locale When a decimal is formatted Then output uses a period`() { + val previous = Locale.getDefault() + try { + Locale.setDefault(Locale.GERMANY) + assertEquals("12.50", ValueFormats.decimal(12.5)) + } finally { + Locale.setDefault(previous) + } + } +} diff --git a/core/src/test/java/com/motionapps/sensorbox/core/time/ClockFormatsTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/time/ClockFormatsTest.kt new file mode 100644 index 0000000..e873945 --- /dev/null +++ b/core/src/test/java/com/motionapps/sensorbox/core/time/ClockFormatsTest.kt @@ -0,0 +1,22 @@ +package com.motionapps.sensorbox.core.time + +import kotlinx.datetime.TimeZone +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Instant + +class ClockFormatsTest { + @Test + fun `Given a clock instant When a folder timestamp is formatted Then the requested zone is used`() { + val millis = Instant.parse("2026-08-23T12:34:56Z").toEpochMilliseconds() + + assertEquals("2026-08-23_14-34-56", ClockFormats.folderTimestamp(millis, TimeZone.of("Europe/Bratislava"))) + } + + @Test + fun `Given a clock instant When metadata is formatted Then the established shape is preserved`() { + val millis = Instant.parse("2026-08-23T12:34:56Z").toEpochMilliseconds() + + assertEquals("23. 08. 2026 12:34:56", ClockFormats.metadataTimestamp(millis, TimeZone.UTC)) + } +} diff --git a/sensorservices/src/main/AndroidManifest.xml b/sensorservices/src/main/AndroidManifest.xml index 632668c..87d7afd 100644 --- a/sensorservices/src/main/AndroidManifest.xml +++ b/sensorservices/src/main/AndroidManifest.xml @@ -10,11 +10,6 @@ - - - - - - - - - - - - - @@ -68,7 +54,7 @@ diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt index 0333b8f..2bb7aa5 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt @@ -1,6 +1,5 @@ package com.motionapps.sensorbox.communication -import android.hardware.Sensor import com.motionapps.sensorbox.core.error.AppError import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.error.AppResult @@ -29,8 +28,7 @@ class AndroidWearCommandEnvironment @Inject constructor( if (!availableSensorIds.containsAll(request.sensorIds)) { return AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear recording sensors")) } - val includesHeartRate = Sensor.TYPE_HEART_RATE in request.sensorIds - val missingPermissions = measurementPermissions(request.includesGps, includesHeartRate) + val missingPermissions = measurementPermissions(request.includesGps) if (missingPermissions.isNotEmpty()) { return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Prepare Wear recording permissions")) } @@ -38,6 +36,6 @@ class AndroidWearCommandEnvironment @Inject constructor( } override fun sensors(): List = getWearSensors().map { sensor -> - WearSensorInfo(sensor.type, sensor.name, sensor.vendor, sensor.isHeartRate) + WearSensorInfo(sensor.type, sensor.name, sensor.vendor) } } diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementPermissionUseCase.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementPermissionUseCase.kt index 3750682..2ad421b 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementPermissionUseCase.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementPermissionUseCase.kt @@ -3,28 +3,20 @@ package com.motionapps.sensorbox.domain.measurement import android.Manifest import android.content.Context import android.content.pm.PackageManager -import android.health.connect.HealthPermissions import android.os.Build import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class WearMeasurementPermissionUseCase @Inject constructor(@ApplicationContext private val context: Context) { - operator fun invoke(includesGps: Boolean, includesHeartRate: Boolean): Set = buildSet { - if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS) - if (includesGps) add(Manifest.permission.ACCESS_FINE_LOCATION) - if (includesHeartRate) addHeartRatePermissions() - }.filterNot(::isGranted).toSet() - - private fun MutableSet.addHeartRatePermissions() { - if (Build.VERSION.SDK_INT >= 36) { - add(HealthPermissions.READ_HEART_RATE) - add(HealthPermissions.READ_HEALTH_DATA_IN_BACKGROUND) - } else { - add(Manifest.permission.BODY_SENSORS) - } - } + operator fun invoke(includesGps: Boolean): Set = + requiredWearMeasurementPermissions(includesGps, Build.VERSION.SDK_INT).filterNot(::isGranted).toSet() private fun isGranted(permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } + +internal fun requiredWearMeasurementPermissions(includesGps: Boolean, sdkInt: Int): Set = buildSet { + if (sdkInt >= 33) add(Manifest.permission.POST_NOTIFICATIONS) + if (includesGps) add(Manifest.permission.ACCESS_FINE_LOCATION) +} diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/sensors/GetWearSensorsUseCase.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/sensors/GetWearSensorsUseCase.kt index f4c6b7d..8e4a568 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/domain/sensors/GetWearSensorsUseCase.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/domain/sensors/GetWearSensorsUseCase.kt @@ -11,7 +11,7 @@ class GetWearSensorsUseCase @Inject constructor(@ApplicationContext context: Con operator fun invoke(): List = sensorManager .getSensorList(Sensor.TYPE_ALL) - .filter { it.type in SUPPORTED_SENSOR_TYPES } + .filter { it.type in WEAR_SENSOR_TYPES } .distinctBy(Sensor::getType) .map { sensor -> sensor.toDescriptor() } .sortedBy(WearSensorDescriptor::name) @@ -20,25 +20,21 @@ class GetWearSensorsUseCase @Inject constructor(@ApplicationContext context: Con type = type, name = name, vendor = vendor, - isHeartRate = type == Sensor.TYPE_HEART_RATE, ) - - private companion object { - val SUPPORTED_SENSOR_TYPES = setOf( - Sensor.TYPE_ACCELEROMETER, - Sensor.TYPE_AMBIENT_TEMPERATURE, - Sensor.TYPE_GRAVITY, - Sensor.TYPE_GYROSCOPE, - Sensor.TYPE_HEART_RATE, - Sensor.TYPE_LIGHT, - Sensor.TYPE_LINEAR_ACCELERATION, - Sensor.TYPE_MAGNETIC_FIELD, - Sensor.TYPE_PRESSURE, - Sensor.TYPE_PROXIMITY, - Sensor.TYPE_RELATIVE_HUMIDITY, - Sensor.TYPE_ROTATION_VECTOR, - Sensor.TYPE_STEP_COUNTER, - Sensor.TYPE_STEP_DETECTOR, - ) - } } + +internal val WEAR_SENSOR_TYPES = setOf( + Sensor.TYPE_ACCELEROMETER, + Sensor.TYPE_AMBIENT_TEMPERATURE, + Sensor.TYPE_GRAVITY, + Sensor.TYPE_GYROSCOPE, + Sensor.TYPE_LIGHT, + Sensor.TYPE_LINEAR_ACCELERATION, + Sensor.TYPE_MAGNETIC_FIELD, + Sensor.TYPE_PRESSURE, + Sensor.TYPE_PROXIMITY, + Sensor.TYPE_RELATIVE_HUMIDITY, + Sensor.TYPE_ROTATION_VECTOR, + Sensor.TYPE_STEP_COUNTER, + Sensor.TYPE_STEP_DETECTOR, +) diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorDescriptor.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorDescriptor.kt index bdb2620..bcda4a5 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorDescriptor.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorDescriptor.kt @@ -1,3 +1,3 @@ package com.motionapps.sensorbox.domain.sensors -data class WearSensorDescriptor(val type: Int, val name: String, val vendor: String, val isHeartRate: Boolean) +data class WearSensorDescriptor(val type: Int, val name: String, val vendor: String) diff --git a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardScreen.kt b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardScreen.kt index b73e00f..e3837ec 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardScreen.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardScreen.kt @@ -29,6 +29,7 @@ import androidx.wear.compose.material3.lazy.TransformationSpec import androidx.wear.compose.material3.lazy.rememberTransformationSpec import androidx.wear.compose.material3.lazy.transformedHeight import com.motionapps.sensorbox.R +import com.motionapps.sensorbox.core.format.ValueFormats import com.motionapps.sensorbox.domain.sensors.WearSensorDescriptor import com.motionapps.sensorbox.presentation.menu.WearMenuScreen import com.motionapps.sensorbox.presentation.menu.WearMenuState @@ -107,7 +108,7 @@ private fun WearLiveScreen( verticalArrangement = Arrangement.Center, ) { Text(selected.name) - Text(state.latestValue?.let { "%.2f".format(it) } ?: stringResource(R.string.waiting)) + Text(state.latestValue?.let { ValueFormats.decimal(it) } ?: stringResource(R.string.waiting)) CartesianChartHost( chart = rememberCartesianChart(rememberLineCartesianLayer()), modelProducer = chartModelProducer, diff --git a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt index 3d4bf04..a9db88e 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt @@ -86,10 +86,7 @@ class WearDashboardViewModel @Inject constructor( mutableState.value = state.copy(message = WearDashboardMessage.PickSource) return } - val includesHeartRate = state.sensors.any { - it.type in state.selectedSensorIds && it.isHeartRate - } - val missing = permissionUseCase(state.includesGps, includesHeartRate) + val missing = permissionUseCase(state.includesGps) if (missing.isEmpty()) startMeasurement() else requestPermissions(missing) } diff --git a/wear/src/test/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementPermissionUseCaseTest.kt b/wear/src/test/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementPermissionUseCaseTest.kt new file mode 100644 index 0000000..bab8d84 --- /dev/null +++ b/wear/src/test/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementPermissionUseCaseTest.kt @@ -0,0 +1,15 @@ +package com.motionapps.sensorbox.domain.measurement + +import android.Manifest +import org.junit.Assert.assertEquals +import org.junit.Test + +class WearMeasurementPermissionUseCaseTest { + @Test + fun `Given Wear GPS When permissions are derived Then only notification and location are requested`() { + assertEquals( + setOf(Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.ACCESS_FINE_LOCATION), + requiredWearMeasurementPermissions(includesGps = true, sdkInt = 37), + ) + } +} diff --git a/wear/src/test/java/com/motionapps/sensorbox/domain/sensors/WearSensorTypesTest.kt b/wear/src/test/java/com/motionapps/sensorbox/domain/sensors/WearSensorTypesTest.kt new file mode 100644 index 0000000..71fddf7 --- /dev/null +++ b/wear/src/test/java/com/motionapps/sensorbox/domain/sensors/WearSensorTypesTest.kt @@ -0,0 +1,12 @@ +package com.motionapps.sensorbox.domain.sensors + +import android.hardware.Sensor +import org.junit.Assert.assertFalse +import org.junit.Test + +class WearSensorTypesTest { + @Test + fun `Given Wear sensor discovery When supported types are inspected Then heart rate is absent`() { + assertFalse(Sensor.TYPE_HEART_RATE in WEAR_SENSOR_TYPES) + } +} From b13276efcebdface6ad7e6e24b2779ed387696b7 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Sun, 23 Aug 2026 23:43:47 +0200 Subject: [PATCH 2/2] Remove recording type discriminator --- .../wearoslib/protocol/WearCommand.kt | 1 - .../wearoslib/protocol/WearCommandCodec.kt | 6 +--- .../protocol/WearCommandCodecTest.kt | 1 - .../domain/measurement/MeasurementRequest.kt | 2 -- .../measurement/PhoneRecordingController.kt | 2 +- .../paired/PairedRecordingCoordinator.kt | 1 - .../main/MeasurementSetupScreen.kt | 29 ++++++------------- .../presentation/main/RecordingContract.kt | 7 +---- app/src/main/res/values/strings.xml | 2 -- .../measurement/MeasurementRequestTest.kt | 1 - .../presentation/main/RecordingReducerTest.kt | 18 ++++++++---- .../main/RecordingStateConversionTest.kt | 5 ++-- .../sensorbox/recording/RecordingSource.kt | 2 +- .../handlers/measurements/ExtraInfoHandler.kt | 3 +- .../intent/MeasurementIntentFactory.kt | 5 ++-- .../intent/MeasurementLaunchRequest.kt | 1 - .../serviceController/MeasurementConfig.kt | 2 -- .../serviceController/ServiceController.kt | 2 +- .../services/MeasurementService.kt | 1 - .../MeasurementMetadataSerializationTest.kt | 4 ++- .../WearMeasurementControlUseCase.kt | 3 -- 21 files changed, 36 insertions(+), 62 deletions(-) diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt index 16686b0..a3c3c31 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt @@ -33,7 +33,6 @@ data class WearRecordingRequest( val sensorIds: List, val includesGps: Boolean, val durationMillis: Long = 0L, - val measurementType: String = "ENDLESS", ) enum class WearSessionCommand { diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt index b497271..931a282 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt @@ -84,7 +84,6 @@ object WearCommandCodec { writeByte(command.request.sensorIds.size) command.request.sensorIds.forEach(::writeInt) writeLong(command.request.durationMillis) - writeUTF(command.request.measurementType) } private fun DataOutputStream.writeAcknowledgement(command: WearCommand.Acknowledgement) { @@ -134,7 +133,6 @@ object WearCommandCodec { sensorIds = sensorIds, includesGps = includesGps, durationMillis = readLong(), - measurementType = readUTF(), ), ) } @@ -169,7 +167,6 @@ object WearCommandCodec { private const val MAX_SENSORS = 64 private const val MAX_FOLDER_LENGTH = 100 -private const val MAX_TYPE_LENGTH = 32 private const val MAX_SENSOR_TEXT_LENGTH = 100 private const val MAX_SESSION_ID_LENGTH = 128 @@ -199,8 +196,7 @@ private fun WearCommand.PrepareRecording.isValid(): Boolean = validSessionId(ses request.folderName.isNotBlank() && request.folderName.length <= MAX_FOLDER_LENGTH && request.sensorIds.size <= MAX_SENSORS && - request.durationMillis >= 0L && - request.measurementType.length <= MAX_TYPE_LENGTH + request.durationMillis >= 0L private fun WearCommand.Acknowledgement.isValid(): Boolean = validSessionId(sessionId) && failureCount >= 0 && diff --git a/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt index fd9b075..c71dd6a 100644 --- a/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt +++ b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt @@ -13,7 +13,6 @@ class WearCommandCodecTest { sensorIds = listOf(1, 4, 21), includesGps = true, durationMillis = 45_000L, - measurementType = "TIMED", ) val commands = listOf( WearCommand.LaunchPhone, diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequest.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequest.kt index 7786ee5..fed97ff 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequest.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequest.kt @@ -14,7 +14,6 @@ data class MeasurementRequest( val wearSensorIds: Set = emptySet(), val wearIncludesGps: Boolean = false, val customName: String = "", - val measurementType: String = "ENDLESS", val delaySeconds: Int = 0, val durationSeconds: Int = 0, val notes: List = emptyList(), @@ -36,7 +35,6 @@ data class MeasurementRequest( useWakeLock = useWakeLock, gpsIntervalSeconds = gpsIntervalSeconds, gpsMinDistanceMeters = gpsMinDistanceMeters, - measurementType = measurementType, durationMillis = durationSeconds.coerceAtLeast(0) * 1_000L, notes = notes, alarmOffsetsSeconds = alarmOffsetsSeconds, diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt index dfc01a8..88b61e1 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt @@ -53,7 +53,7 @@ class AndroidPhoneRecordingController @Inject constructor( sessionId = sessionId, launchRequest = request.toLaunchRequest( sessionId, - intentFactory.newFolderName(request.customName, request.measurementType), + intentFactory.newFolderName(request.customName), ), ), ) diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt index 13cea41..739175e 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt @@ -206,7 +206,6 @@ class PairedRecordingCoordinator @Inject constructor( sensorIds = request.wearSensorIds.sorted(), includesGps = request.wearIncludesGps, durationMillis = launchRequest.durationMillis, - measurementType = launchRequest.measurementType, ) private fun WearCommand.Acknowledgement.toResult(retryCount: Int): AppResult = diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt index 6bf7078..d631376 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt @@ -49,7 +49,7 @@ private fun MeasurementSetupContent(state: RecordingState, onIntent: (RecordingI item { SensorBoxTopAppBar(stringResource(R.string.measurement_setup), onBack) } item { StorageSetupPanel(state.storagePath) { onIntent(RecordingIntent.ChooseStorage) } } item { MeasurementNameSetup(state, onIntent) } - item { TimingSetup(state, onIntent) } + item { RecordingTimingSetup(state, onIntent) } item { NotesAndAlarmsSetup(state, onIntent) } item { SamplingSetting(state.preferences.recording.sensorSamplingPeriod) { index -> @@ -83,13 +83,8 @@ private fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingInte } @Composable -private fun TimingSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun RecordingTimingSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - BooleanSetting( - title = stringResource(R.string.timed_measurement), - description = stringResource(R.string.timed_measurement_description), - checked = state.measurementType == "TIMED", - ) { onIntent(RecordingIntent.SetMeasurementType(if (it) "TIMED" else "ENDLESS")) } StepSetting( stringResource(R.string.start_delay), state.startDelaySeconds, @@ -97,19 +92,13 @@ private fun TimingSetup(state: RecordingState, onIntent: (RecordingIntent) -> Un 0, 86_400, ) { onIntent(RecordingIntent.SetStartDelay(it)) } - if (state.measurementType == "TIMED") { - StepSetting( - stringResource(R.string.measurement_duration), - state.durationSeconds.coerceAtLeast(1), - pluralStringResource( - R.plurals.seconds_count, - state.durationSeconds.coerceAtLeast(1), - state.durationSeconds.coerceAtLeast(1), - ), - 1, - 86_400, - ) { onIntent(RecordingIntent.SetDuration(it)) } - } + StepSetting( + stringResource(R.string.measurement_duration), + state.durationSeconds, + pluralStringResource(R.plurals.seconds_count, state.durationSeconds, state.durationSeconds), + 0, + 86_400, + ) { onIntent(RecordingIntent.SetDuration(it)) } } } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt index 2ca06f5..514cbf5 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt @@ -23,7 +23,6 @@ data class RecordingState( val selectedWearSensorIds: Set = emptySet(), val wearIncludesGps: Boolean = false, val customMeasurementName: String = "", - val measurementType: String = "ENDLESS", val startDelaySeconds: Int = 0, val durationSeconds: Int = 0, val notes: String = "", @@ -50,9 +49,8 @@ data class RecordingState( wearSensorIds = selectedWearSensorIds, wearIncludesGps = wearIncludesGps, customName = customMeasurementName, - measurementType = measurementType, delaySeconds = startDelaySeconds, - durationSeconds = if (measurementType == "TIMED") durationSeconds.coerceAtLeast(1) else 0, + durationSeconds = durationSeconds.coerceAtLeast(0), notes = notes.lines().map(String::trim).filter(String::isNotEmpty), alarmOffsetsSeconds = alarmOffsets.split(',', ';', ' ') .mapNotNull(String::toIntOrNull).filter { it >= 0 }, @@ -76,7 +74,6 @@ sealed interface RecordingIntent { data object StopMeasurement : RecordingIntent data object ClearMessage : RecordingIntent data class SetCustomMeasurementName(val value: String) : RecordingIntent - data class SetMeasurementType(val value: String) : RecordingIntent data class SetStartDelay(val seconds: Int) : RecordingIntent data class SetDuration(val seconds: Int) : RecordingIntent data class SetNotes(val value: String) : RecordingIntent @@ -141,8 +138,6 @@ object RecordingReducer { private fun reduceConfiguration(state: RecordingState, intent: RecordingIntent): RecordingNext = when (intent) { is RecordingIntent.SetCustomMeasurementName -> RecordingNext(state.copy(customMeasurementName = intent.value)) - is RecordingIntent.SetMeasurementType -> RecordingNext(state.copy(measurementType = intent.value)) - is RecordingIntent.SetStartDelay -> RecordingNext( state.copy(startDelaySeconds = intent.seconds.coerceAtLeast(0)), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c68636a..e0ff3be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,8 +114,6 @@ Start measurement Measurement name Optional folder-name prefix - Timed measurement - Stop and save automatically after a duration Start delay Duration Notes diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequestTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequestTest.kt index b00d559..770af43 100644 --- a/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequestTest.kt +++ b/app/src/test/java/com/motionapps/sensorbox/domain/measurement/MeasurementRequestTest.kt @@ -15,7 +15,6 @@ class MeasurementRequestTest { useWakeLock = true, gpsIntervalSeconds = 12, gpsMinDistanceMeters = 3, - measurementType = "TIMED", durationSeconds = 45, notes = listOf("note"), alarmOffsetsSeconds = listOf(5), diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt index 0aa4a52..e244d92 100644 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt @@ -43,13 +43,21 @@ class RecordingReducerTest { } @Test - fun `Given endless mode When timed mode is selected Then timing configuration is retained`() { - val givenState = RecordingStateFixtures.state().copy(durationSeconds = 60) + fun `Given a negative start delay When changed Then it is clamped to zero`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.SetStartDelay(-1)) + + assertEquals(0, actual.state.startDelaySeconds) + } + + @Test + fun `Given a negative duration When changed Then it is clamped to zero`() { + val givenState = RecordingStateFixtures.state() - val actual = RecordingReducer.reduce(givenState, RecordingIntent.SetMeasurementType("TIMED")) + val actual = RecordingReducer.reduce(givenState, RecordingIntent.SetDuration(-1)) - assertEquals("TIMED", actual.state.measurementType) - assertEquals(60, actual.state.durationSeconds) + assertEquals(0, actual.state.durationSeconds) } @Test diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateConversionTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateConversionTest.kt index af87341..66aa44f 100644 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateConversionTest.kt +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateConversionTest.kt @@ -12,8 +12,7 @@ class RecordingStateConversionTest { selectedSensorIds = setOf(1), includesGps = true, customMeasurementName = "walk", - measurementType = "TIMED", - durationSeconds = 0, + durationSeconds = -1, notes = " first \n\n second ", alarmOffsets = "10, bad; -2 30", preferences = AppPreferences( @@ -28,7 +27,7 @@ class RecordingStateConversionTest { val request = state.toMeasurementRequest() assertEquals(setOf(1), request.sensorIds) - assertEquals(1, request.durationSeconds) + assertEquals(0, request.durationSeconds) assertEquals(listOf("first", "second"), request.notes) assertEquals(listOf(10, 30), request.alarmOffsetsSeconds) assertEquals(3, request.samplingPeriodIndex) diff --git a/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt index 908737f..fd70b6e 100644 --- a/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt +++ b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt @@ -17,5 +17,5 @@ fun interface RecordingClock { } fun interface RecordingDelay { - suspend fun pause(durationMillis: Long) + suspend fun pause(delayMillis: Long) } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt index eec9fba..20faeeb 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt @@ -51,7 +51,7 @@ internal class ExtraInfoHandler(private val storage: MeasurementStorage, private MeasurementMetadata( millis = startedAtMillis, nanos = startedAtNanos, - type = active.measurementType, + type = RECORDING_TYPE, date = ClockFormats.metadataTimestamp(startedAtMillis), folder = active.folderName, notes = active.notes, @@ -94,6 +94,7 @@ internal class ExtraInfoHandler(private val storage: MeasurementStorage, private private companion object { const val EXTRA_FILE = "extra.json" + const val RECORDING_TYPE = "RECORDING" val JSON = Json { prettyPrint = true prettyPrintIndent = " " diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt index 41a1494..d6a7c33 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt @@ -23,7 +23,6 @@ class MeasurementIntentFactory @Inject constructor( putExtra(MeasurementService.USE_WAKE_LOCK, request.useWakeLock) putExtra(MeasurementService.GPS_INTERVAL_SECONDS, request.gpsIntervalSeconds) putExtra(MeasurementService.GPS_DISTANCE_METERS, request.gpsMinDistanceMeters) - putExtra(MeasurementService.MEASUREMENT_TYPE, request.measurementType) putExtra(MeasurementService.START_AT_EPOCH_MILLIS, request.startAtEpochMillis) putExtra(MeasurementService.DURATION_MILLIS, request.durationMillis) putStringArrayListExtra(MeasurementService.NOTES, ArrayList(request.notes)) @@ -33,9 +32,9 @@ class MeasurementIntentFactory @Inject constructor( putExtra(MeasurementService.SIGNIFICANT_MOTION, request.significantMotion) } - fun newFolderName(customName: String = "", measurementType: String = "ENDLESS"): String { + fun newFolderName(customName: String = ""): String { val prefix = customName.trim().replace(INVALID_NAME_CHARS, "_").trim('_').take(MAX_PREFIX_LENGTH) - .ifBlank { if (measurementType == "TIMED") "timed" else "recording" } + .ifBlank { "recording" } val timestamp = ClockFormats.folderTimestamp(clock.nowMillis()) return "${prefix}_$timestamp" } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt index b4e31cc..bbc338d 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt @@ -13,7 +13,6 @@ data class MeasurementLaunchRequest( val useWakeLock: Boolean, val gpsIntervalSeconds: Int, val gpsMinDistanceMeters: Int, - val measurementType: String = "ENDLESS", val startAtEpochMillis: Long = SystemEpochClock.nowMillis(), val durationMillis: Long = 0L, val notes: List = emptyList(), diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt index 9837a55..da2e933 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt @@ -16,7 +16,6 @@ data class MeasurementConfig( val useWakeLock: Boolean, val gpsIntervalSeconds: Int, val gpsMinDistanceMeters: Int, - val measurementType: String, val startAtEpochMillis: Long, val durationMillis: Long, val notes: List, @@ -40,7 +39,6 @@ data class MeasurementConfig( useWakeLock = intent.getBooleanExtra(MeasurementService.USE_WAKE_LOCK, false), gpsIntervalSeconds = intent.getIntExtra(MeasurementService.GPS_INTERVAL_SECONDS, 10), gpsMinDistanceMeters = intent.getIntExtra(MeasurementService.GPS_DISTANCE_METERS, 20), - measurementType = intent.getStringExtra(MeasurementService.MEASUREMENT_TYPE) ?: "ENDLESS", startAtEpochMillis = intent.getLongExtra( MeasurementService.START_AT_EPOCH_MILLIS, clock.nowMillis(), diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt index dc375e7..e41d94e 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt @@ -31,7 +31,7 @@ internal class ServiceController( sources = androidSources.sources, scope = scope, clock = RecordingClock(clock::nowMillis), - delay = RecordingDelay { durationMillis -> delay(durationMillis) }, + delay = RecordingDelay { delayMillis -> delay(delayMillis) }, ) val events: SharedFlow = engine.events diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt index b25fa38..5497967 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt @@ -295,7 +295,6 @@ class MeasurementService : Service() { const val USE_WAKE_LOCK = "USE_WAKE_LOCK" const val GPS_INTERVAL_SECONDS = "GPS_INTERVAL_SECONDS" const val GPS_DISTANCE_METERS = "GPS_DISTANCE_METERS" - const val MEASUREMENT_TYPE = "MEASUREMENT_TYPE" const val START_AT_EPOCH_MILLIS = "START_AT_EPOCH_MILLIS" const val DURATION_MILLIS = "DURATION_MILLIS" const val NOTES = "NOTES" diff --git a/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt index 6ea302f..211234a 100644 --- a/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt +++ b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt @@ -18,7 +18,7 @@ class MeasurementMetadataSerializationTest { val metadata = MeasurementMetadata( millis = 100L, nanos = 200L, - type = "TIMED", + type = "RECORDING", date = "23. 08. 2026 12:00:00", folder = "fixture", notes = listOf("note"), @@ -52,6 +52,8 @@ class MeasurementMetadataSerializationTest { json.keys, ) assertEquals(100L, json.getValue("millis").jsonPrimitive.long) + assertEquals("RECORDING", json.getValue("type").jsonPrimitive.content) + assertEquals(60_000L, json.getValue("durationMillis").jsonPrimitive.long) assertEquals("note", json.getValue("notes").jsonArray.single().jsonPrimitive.content) val annotation = json.getValue("annotations").jsonArray.single().jsonObject val range = json.getValue("ranges").jsonArray.single().jsonObject diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt index a022811..81bdca3 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt @@ -46,7 +46,6 @@ class WearMeasurementControlUseCase @Inject constructor( folderName = request.folderName, startAtEpochMillis = startAtEpochMillis, durationMillis = request.durationMillis, - measurementType = request.measurementType, ) fun start( @@ -57,7 +56,6 @@ class WearMeasurementControlUseCase @Inject constructor( folderName: String = intentFactory.newFolderName(), startAtEpochMillis: Long = clock.nowMillis(), durationMillis: Long = 0L, - measurementType: String = "ENDLESS", ): AppResult = appResult(AppErrorCode.MEASUREMENT, "Request Wear measurement start") { val request = MeasurementLaunchRequest( sessionId = sessionId, @@ -70,7 +68,6 @@ class WearMeasurementControlUseCase @Inject constructor( useWakeLock = preferences.recording.useWakeLock, gpsIntervalSeconds = preferences.recording.gpsIntervalSeconds, gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, - measurementType = measurementType, startAtEpochMillis = startAtEpochMillis, durationMillis = durationMillis, )