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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ data class WearRecordingRequest(
val sensorIds: List<Int>,
val includesGps: Boolean,
val durationMillis: Long = 0L,
val measurementType: String = "ENDLESS",
)

enum class WearSessionCommand {
Expand All @@ -58,4 +57,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)
Original file line number Diff line number Diff line change
Expand Up @@ -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<ByteArray> = validate(command).flatMap {
appResult(AppErrorCode.CONNECTIVITY, "Encode Wear command") {
Expand All @@ -28,7 +28,7 @@ object WearCommandCodec {

fun decode(payload: ByteArray): AppResult<WearCommand> = 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" }
Expand All @@ -40,7 +40,7 @@ object WearCommandCodec {
private fun validate(command: WearCommand): AppResult<Unit> = 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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -104,7 +103,6 @@ object WearCommandCodec {
writeInt(sensor.type)
writeUTF(sensor.name)
writeUTF(sensor.vendor)
writeBoolean(sensor.isHeartRate)
}
}

Expand Down Expand Up @@ -135,7 +133,6 @@ object WearCommandCodec {
sensorIds = sensorIds,
includesGps = includesGps,
durationMillis = readLong(),
measurementType = readUTF(),
),
)
}
Expand All @@ -152,11 +149,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
Expand All @@ -170,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

Expand Down Expand Up @@ -200,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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,18 @@ 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),
includesGps = true,
durationMillis = 45_000L,
measurementType = "TIMED",
)
val commands = listOf(
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"),
Expand Down Expand Up @@ -51,13 +50,27 @@ 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()

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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 1 addition & 12 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,14 @@
</intent-filter>
</activity>

<activity-alias
android:name=".HealthPermissionRationaleActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".activities.MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>

<service
android:name=".MsgListener"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:host="*"
android:pathPrefix="/sensorbox/v2/phone"
android:pathPrefix="/sensorbox/v3/phone"
android:scheme="wear" />
</intent-filter>
<intent-filter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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<String>.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<String> =
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<String> = 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)
}
Original file line number Diff line number Diff line change
@@ -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<Int>,
val includesGps: Boolean,
Expand All @@ -11,12 +14,41 @@ data class MeasurementRequest(
val wearSensorIds: Set<Int> = emptySet(),
val wearIncludesGps: Boolean = false,
val customName: String = "",
val measurementType: String = "ENDLESS",
val delaySeconds: Int = 0,
val durationSeconds: Int = 0,
val notes: List<String> = emptyList(),
val alarmOffsetsSeconds: List<Int> = emptyList(),
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,
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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
),
),
)
}
Expand Down Expand Up @@ -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,
)
}
}
Loading