diff --git a/WearOsLib/build.gradle.kts b/WearOsLib/build.gradle.kts index 4697001..4100136 100644 --- a/WearOsLib/build.gradle.kts +++ b/WearOsLib/build.gradle.kts @@ -40,7 +40,7 @@ android { } dependencies { - implementation(project(":core")) + implementation(project(":core-common")) implementation(libs.androidx.core.ktx) implementation(libs.play.services.wearable) @@ -49,6 +49,7 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) testImplementation(libs.junit) + testFixturesImplementation(project(":core-common")) testFixturesImplementation(libs.coroutines.core) } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt index 7db9cd1..283f93a 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/v1/phone" + const val PHONE_MESSAGE_PATH = "/sensorbox/v2/phone" const val WEAR_APP_CAPABILITY = "wear_app" - const val WEAR_MESSAGE_PATH = "/sensorbox/v1/wear" + const val WEAR_MESSAGE_PATH = "/sensorbox/v2/wear" } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt index 5e123db..0804039 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt @@ -6,6 +6,8 @@ import com.google.android.gms.wearable.CapabilityInfo import com.google.android.gms.wearable.Node import com.google.android.gms.wearable.Wearable import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.suspendAppResult import com.motionapps.sensorbox.core.error.suspendFlatMap import dagger.hilt.android.qualifiers.ApplicationContext @@ -31,7 +33,7 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext trySend(loadConnection(capability)) awaitClose { capabilityClient.removeListener(listener) } }.catch { error -> - AppError.from(AppError.Kind.CONNECTIVITY, "Observe Wear connection", error) + AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error) emit(WearConnection.Disconnected) } @@ -42,13 +44,13 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext return WearNodeSelector.select(info.nodes.map { it.toWearNode() }) } - override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): Result = - suspendAppResult(AppError.Kind.CONNECTIVITY, "Find Wear node") { findNode(capability) } + override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult = + suspendAppResult(AppErrorCode.CONNECTIVITY, "Find Wear node") { findNode(capability) } .suspendFlatMap { node -> if (node == null) { - Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Find reachable Wear node for $capability")) + AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Find reachable Wear node for $capability")) } else { - suspendAppResult(AppError.Kind.CONNECTIVITY, "Send Wear message") { + suspendAppResult(AppErrorCode.CONNECTIVITY, "Send Wear message") { messageClient.sendMessage(node.id, path, payload).await() Unit } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt index f04a0cf..518a4bb 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt @@ -1,5 +1,6 @@ package com.motionapps.wearoslib.connectivity +import com.motionapps.sensorbox.core.error.AppResult import kotlinx.coroutines.flow.Flow interface WearConnectionRepository { @@ -7,5 +8,5 @@ interface WearConnectionRepository { suspend fun findNode(capability: String): WearNode? - suspend fun sendMessage(capability: String, path: String, payload: ByteArray): Result + suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt index d884c24..32cd61e 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt @@ -1,5 +1,6 @@ package com.motionapps.wearoslib.connectivity +import com.motionapps.sensorbox.core.error.AppResult import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -8,9 +9,9 @@ class ObserveWearCapabilityUseCase @Inject constructor(private val repository: W } class SendWearMessageUseCase @Inject constructor(private val repository: WearConnectionRepository) { - suspend operator fun invoke(capability: String, path: String, message: String): Result = + suspend operator fun invoke(capability: String, path: String, message: String): AppResult = invoke(capability, path, message.encodeToByteArray()) - suspend operator fun invoke(capability: String, path: String, payload: ByteArray): Result = + suspend operator fun invoke(capability: String, path: String, payload: ByteArray): AppResult = repository.sendMessage(capability, path, payload) } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt index b5fef8b..4a78063 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt @@ -3,7 +3,8 @@ package com.motionapps.wearoslib.files import android.content.Context import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.Wearable -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.suspendAppResult import com.motionapps.sensorbox.core.error.suspendFlatMap @@ -22,36 +23,36 @@ class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext c nodeId: String, metadata: WearFileMetadata, input: () -> java.io.InputStream, - ): Result = withContext(Dispatchers.IO) { + ): AppResult = withContext(Dispatchers.IO) { WearFilePathCodec.encode(metadata).suspendFlatMap { path -> - suspendAppResult(AppError.Kind.CONNECTIVITY, "Open Wear channel") { + suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear channel") { channelClient.openChannel(nodeId, path).await() } }.suspendFlatMap { channel -> - val transfer = suspendAppResult(AppError.Kind.CONNECTIVITY, "Write Wear channel") { + val transfer = suspendAppResult(AppErrorCode.CONNECTIVITY, "Write Wear channel") { input().use { source -> channelClient.getOutputStream(channel).await().use(source::copyTo) } } - val close = suspendAppResult(AppError.Kind.CONNECTIVITY, "Close Wear channel") { + val close = suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") { channelClient.close(channel).await() } - listOf(transfer, close).combineAppResults(AppError.Kind.CONNECTIVITY, "Send Wear file") - }.withAppError(AppError.Kind.CONNECTIVITY, "Send Wear file") + listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Send Wear file") + }.withAppError(AppErrorCode.CONNECTIVITY, "Send Wear file") } override suspend fun receive( channel: ChannelClient.Channel, - consume: (java.io.InputStream) -> Result, - ): Result = withContext(Dispatchers.IO) { - val transfer = suspendAppResult(AppError.Kind.CONNECTIVITY, "Open Wear input stream") { + consume: (java.io.InputStream) -> AppResult, + ): AppResult = withContext(Dispatchers.IO) { + val transfer = suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear input stream") { channelClient.getInputStream(channel).await() }.suspendFlatMap { input -> input.use(consume) } - val close = suspendAppResult(AppError.Kind.CONNECTIVITY, "Close Wear channel") { + val close = suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") { channelClient.close(channel).await() } - listOf(transfer, close).combineAppResults(AppError.Kind.CONNECTIVITY, "Receive Wear file") + listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Receive Wear file") } } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt index 4f63275..c9bc1fb 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt @@ -1,46 +1,89 @@ package com.motionapps.wearoslib.files import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap -import java.util.Base64 object WearFilePathCodec { - fun encode(metadata: WearFileMetadata): Result = if ( + fun encode(metadata: WearFileMetadata): AppResult = if ( metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart() ) { - appResult(AppError.Kind.CONNECTIVITY, "Encode Wear file path") { + appResult(AppErrorCode.CONNECTIVITY, "Encode Wear file path") { "$PREFIX/${metadata.measurementName.encodePart()}/${metadata.fileName.encodePart()}" } } else { - Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path")) } - fun decode(path: String): Result { + fun decode(path: String): AppResult { val parts = path.removePrefix("$PREFIX/").split('/') if (!path.startsWith("$PREFIX/") || parts.size != 2) { - return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + return AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path")) } - return appResult(AppError.Kind.CONNECTIVITY, "Decode Wear file path") { + return appResult(AppErrorCode.CONNECTIVITY, "Decode Wear file path") { WearFileMetadata(parts[0].decodePart(), parts[1].decodePart()) }.flatMap { metadata -> if (metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart()) { - Result.success(metadata) + AppResult.success(metadata) } else { - Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path")) } } } - private fun String.encodePart(): String = Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(encodeToByteArray()) + private fun String.encodePart(): String = encodeToByteArray().toBase64Url() - private fun String.decodePart(): String = Base64.getUrlDecoder().decode(this).decodeToString() + private fun String.decodePart(): String = decodeBase64Url().decodeToString() + + private fun ByteArray.toBase64Url(): String = buildString((size * 4 + 2) / 3) { + var index = 0 + while (index < size) { + val first = this@toBase64Url[index++].toInt() and BYTE_MASK + append(BASE64_URL_ALPHABET[first ushr 2]) + if (index < size) { + val second = this@toBase64Url[index++].toInt() and BYTE_MASK + append(BASE64_URL_ALPHABET[(first and 0x03) shl 4 or (second ushr 4)]) + if (index < size) { + val third = this@toBase64Url[index++].toInt() and BYTE_MASK + append(BASE64_URL_ALPHABET[(second and 0x0F) shl 2 or (third ushr 6)]) + append(BASE64_URL_ALPHABET[third and 0x3F]) + } else { + append(BASE64_URL_ALPHABET[(second and 0x0F) shl 2]) + } + } else { + append(BASE64_URL_ALPHABET[(first and 0x03) shl 4]) + } + } + } + + private fun String.decodeBase64Url(): ByteArray { + require(length % 4 != 1) { "Invalid URL-safe Base64 length" } + val output = ByteArray(length * 3 / 4) + var outputIndex = 0 + var buffer = 0 + var bitCount = 0 + for (character in this) { + val value = BASE64_URL_ALPHABET.indexOf(character) + require(value >= 0) { "Invalid URL-safe Base64 character" } + buffer = buffer shl 6 or value + bitCount += 6 + if (bitCount >= 8) { + bitCount -= 8 + output[outputIndex++] = (buffer ushr bitCount).toByte() + buffer = buffer and ((1 shl bitCount) - 1) + } + } + require(buffer == 0) { "Invalid URL-safe Base64 trailing bits" } + return output.copyOf(outputIndex) + } private fun String.isSafePathPart(): Boolean = isNotBlank() && length <= MAX_PART_LENGTH && '/' !in this && '\\' !in this && this != "." && this != ".." const val PREFIX = "/sensorbox/v1/file" private const val MAX_PART_LENGTH = 120 + private const val BYTE_MASK = 0xFF + private const val BASE64_URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt index 5dd106b..1c22dc5 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt @@ -1,10 +1,11 @@ package com.motionapps.wearoslib.files import com.google.android.gms.wearable.ChannelClient +import com.motionapps.sensorbox.core.error.AppResult import java.io.InputStream interface WearFileTransferClient { - suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): Result + suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): AppResult - suspend fun receive(channel: ChannelClient.Channel, consume: (InputStream) -> Result): Result + suspend fun receive(channel: ChannelClient.Channel, consume: (InputStream) -> AppResult): AppResult } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/SendWearCommandUseCase.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/SendWearCommandUseCase.kt new file mode 100644 index 0000000..4b5ed19 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/SendWearCommandUseCase.kt @@ -0,0 +1,13 @@ +package com.motionapps.wearoslib.protocol + +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +import javax.inject.Inject + +class SendWearCommandUseCase @Inject constructor(private val sendMessage: SendWearMessageUseCase) { + suspend operator fun invoke(capability: String, path: String, command: WearCommand): AppResult = + WearCommandCodec.encode(command).suspendFlatMap { payload -> + sendMessage(capability, path, payload) + } +} 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 c2a18cc..4fc3633 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt @@ -1,24 +1,61 @@ package com.motionapps.wearoslib.protocol +import com.motionapps.sensorbox.core.error.AppErrorCode + sealed interface WearCommand { data object LaunchPhone : WearCommand - data class StartMeasurement( - val folderName: String, - val sensorIds: List, - val includesGps: Boolean, - val startAtEpochMillis: Long = System.currentTimeMillis(), - val durationMillis: Long = 0L, - val measurementType: String = "ENDLESS", - ) : WearCommand - - data object StopMeasurement : WearCommand - data object SyncMeasurements : WearCommand data object RequestSensorList : WearCommand data class SensorList(val sensors: List) : WearCommand + + data class PrepareRecording(val sessionId: String, val request: WearRecordingRequest) : WearCommand + + data class CommitRecording(val sessionId: String, val startAtEpochMillis: Long) : WearCommand + + data class AbortRecording(val sessionId: String) : WearCommand + + data class StopRecording(val sessionId: String, val reason: WearStopReason) : WearCommand + + data class Acknowledgement( + val sessionId: String, + val command: WearSessionCommand, + val outcome: WearAcknowledgementOutcome, + val errorCode: AppErrorCode? = null, + val failureCount: Int = 0, + ) : WearCommand +} + +data class WearRecordingRequest( + val folderName: String, + val sensorIds: List, + val includesGps: Boolean, + val durationMillis: Long = 0L, + val measurementType: String = "ENDLESS", +) + +enum class WearSessionCommand { + PREPARE, + COMMIT, + ABORT, + STOP, +} + +enum class WearAcknowledgementOutcome { + SUCCEEDED, + REJECTED, + FAILED, +} + +enum class WearStopReason { + USER_REQUEST, + DURATION_EXPIRED, + LOW_BATTERY, + SOURCE_FAILURE, + PAIRED_ABORT, + SERVICE_DESTROYED, } data class WearSensorInfo(val type: Int, val name: String, val vendor: String, val isHeartRate: Boolean) 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 e6311a9..0415be4 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt @@ -1,6 +1,8 @@ package com.motionapps.wearoslib.protocol import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import java.io.ByteArrayInputStream @@ -9,20 +11,14 @@ import java.io.DataInputStream import java.io.DataOutputStream object WearCommandCodec { - fun encode(command: WearCommand): Result { - val itemCount = when (command) { - is WearCommand.SensorList -> command.sensors.size - is WearCommand.StartMeasurement -> command.sensorIds.size - else -> 0 - } - if (itemCount > MAX_SENSORS) { - return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command")) - } - return appResult(AppError.Kind.CONNECTIVITY, "Encode Wear command") { + const val PROTOCOL_VERSION = 2 + + fun encode(command: WearCommand): AppResult = validate(command).flatMap { + appResult(AppErrorCode.CONNECTIVITY, "Encode Wear command") { ByteArrayOutputStream().use { bytes -> DataOutputStream(bytes).use { output -> output.writeInt(MAGIC) - output.writeByte(VERSION) + output.writeByte(PROTOCOL_VERSION) output.writeCommand(command) } bytes.toByteArray() @@ -30,107 +26,198 @@ object WearCommandCodec { } } - fun decode(payload: ByteArray): Result = appResult(AppError.Kind.CONNECTIVITY, "Read Wear command") { + fun decode(payload: ByteArray): AppResult = appResult( + AppErrorCode.CONNECTIVITY, + "Decode Wear protocol v2 command", + ) { DataInputStream(ByteArrayInputStream(payload)).use { input -> - val validHeader = input.readInt() == MAGIC && input.readUnsignedByte() == VERSION - if (validHeader) { - input.readCommand() - } else { - Result.failure( - AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command header"), - ) - } + require(input.readInt() == MAGIC) { "Unsupported Wear protocol magic" } + require(input.readUnsignedByte() == PROTOCOL_VERSION) { "Unsupported Wear protocol version" } + input.readCommand().also { require(input.available() == 0) { "Trailing Wear command bytes" } } } - }.flatMap { it } + }.flatMap { command -> validate(command).map { command } } + + private fun validate(command: WearCommand): AppResult = if (command.isValid()) { + AppResult.success(Unit) + } else { + AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear protocol v2 command")) + } private fun DataOutputStream.writeCommand(command: WearCommand) { when (command) { WearCommand.LaunchPhone -> writeByte(TYPE_LAUNCH_PHONE) - is WearCommand.StartMeasurement -> writeMeasurement(command) - WearCommand.StopMeasurement -> writeByte(TYPE_STOP_MEASUREMENT) + WearCommand.SyncMeasurements -> writeByte(TYPE_SYNC_MEASUREMENTS) + WearCommand.RequestSensorList -> writeByte(TYPE_REQUEST_SENSOR_LIST) + is WearCommand.SensorList -> writeSensorList(command) + + is WearCommand.PrepareRecording -> writePrepare(command) + + is WearCommand.CommitRecording -> { + writeByte(TYPE_COMMIT_RECORDING) + writeUTF(command.sessionId) + writeLong(command.startAtEpochMillis) + } + + is WearCommand.AbortRecording -> { + writeByte(TYPE_ABORT_RECORDING) + writeUTF(command.sessionId) + } + + is WearCommand.StopRecording -> { + writeByte(TYPE_STOP_RECORDING) + writeUTF(command.sessionId) + writeUTF(command.reason.name) + } + + is WearCommand.Acknowledgement -> writeAcknowledgement(command) } } + private fun DataOutputStream.writePrepare(command: WearCommand.PrepareRecording) { + writeByte(TYPE_PREPARE_RECORDING) + writeUTF(command.sessionId) + writeUTF(command.request.folderName) + writeBoolean(command.request.includesGps) + 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) { + writeByte(TYPE_ACKNOWLEDGEMENT) + writeUTF(command.sessionId) + writeUTF(command.command.name) + writeUTF(command.outcome.name) + writeBoolean(command.errorCode != null) + command.errorCode?.let { writeUTF(it.name) } + writeInt(command.failureCount) + } + private fun DataOutputStream.writeSensorList(command: WearCommand.SensorList) { writeByte(TYPE_SENSOR_LIST) writeByte(command.sensors.size) command.sensors.forEach { sensor -> writeInt(sensor.type) - writeUTF(sensor.name.take(MAX_SENSOR_TEXT_LENGTH)) - writeUTF(sensor.vendor.take(MAX_SENSOR_TEXT_LENGTH)) + writeUTF(sensor.name) + writeUTF(sensor.vendor) writeBoolean(sensor.isHeartRate) } } - private fun DataOutputStream.writeMeasurement(command: WearCommand.StartMeasurement) { - writeByte(TYPE_START_MEASUREMENT) - writeUTF(command.folderName.take(MAX_FOLDER_LENGTH)) - writeBoolean(command.includesGps) - writeByte(command.sensorIds.size) - command.sensorIds.forEach(::writeInt) - writeLong(command.startAtEpochMillis) - writeLong(command.durationMillis) - writeUTF(command.measurementType.take(MAX_TYPE_LENGTH)) - } - - private fun DataInputStream.readCommand(): Result = when (readUnsignedByte()) { - TYPE_LAUNCH_PHONE -> Result.success(WearCommand.LaunchPhone) - TYPE_START_MEASUREMENT -> readMeasurement() - TYPE_STOP_MEASUREMENT -> Result.success(WearCommand.StopMeasurement) - TYPE_SYNC_MEASUREMENTS -> Result.success(WearCommand.SyncMeasurements) - TYPE_REQUEST_SENSOR_LIST -> Result.success(WearCommand.RequestSensorList) + private fun DataInputStream.readCommand(): WearCommand = when (readUnsignedByte()) { + TYPE_LAUNCH_PHONE -> WearCommand.LaunchPhone + TYPE_SYNC_MEASUREMENTS -> WearCommand.SyncMeasurements + TYPE_REQUEST_SENSOR_LIST -> WearCommand.RequestSensorList TYPE_SENSOR_LIST -> readSensorList() - else -> Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command type")) + TYPE_PREPARE_RECORDING -> readPrepare() + TYPE_COMMIT_RECORDING -> WearCommand.CommitRecording(readUTF(), readLong()) + TYPE_ABORT_RECORDING -> WearCommand.AbortRecording(readUTF()) + TYPE_STOP_RECORDING -> WearCommand.StopRecording(readUTF(), readNamedEnum(WearStopReason.entries)) + TYPE_ACKNOWLEDGEMENT -> readAcknowledgement() + else -> error("Unknown Wear command type") } - private fun DataInputStream.readSensorList(): Result { - val count = readUnsignedByte() - if (count > MAX_SENSORS) { - return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear sensor count")) - } - return Result.success( - WearCommand.SensorList( - List(count) { WearSensorInfo(readInt(), readUTF(), readUTF(), readBoolean()) }, - ), - ) - } - - private fun DataInputStream.readMeasurement(): Result { + private fun DataInputStream.readPrepare(): WearCommand.PrepareRecording { + val sessionId = readUTF() val folderName = readUTF() val includesGps = readBoolean() val sensorCount = readUnsignedByte() - if (sensorCount > MAX_SENSORS) { - return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear sensor count")) - } + require(sensorCount <= MAX_SENSORS) { "Too many Wear sensors" } val sensorIds = List(sensorCount) { readInt() } - val startAt = if (available() >= Long.SIZE_BYTES) readLong() else System.currentTimeMillis() - val duration = if (available() >= Long.SIZE_BYTES) readLong() else 0L - val type = if (available() > 0) readUTF() else "ENDLESS" - return Result.success( - WearCommand.StartMeasurement( + return WearCommand.PrepareRecording( + sessionId = sessionId, + request = WearRecordingRequest( folderName = folderName, sensorIds = sensorIds, includesGps = includesGps, - startAtEpochMillis = startAt, - durationMillis = duration, - measurementType = type, + durationMillis = readLong(), + measurementType = readUTF(), ), ) } - private const val MAGIC = 0x53425831 - private const val VERSION = 1 + private fun DataInputStream.readAcknowledgement(): WearCommand.Acknowledgement { + val sessionId = readUTF() + val command = readNamedEnum(WearSessionCommand.entries) + val outcome = readNamedEnum(WearAcknowledgementOutcome.entries) + val errorCode = if (readBoolean()) readNamedEnum(AppErrorCode.entries) else null + return WearCommand.Acknowledgement(sessionId, command, outcome, errorCode, readInt()) + } + + private fun DataInputStream.readSensorList(): WearCommand.SensorList { + val count = readUnsignedByte() + require(count <= MAX_SENSORS) { "Too many Wear sensors" } + return WearCommand.SensorList( + List(count) { WearSensorInfo(readInt(), readUTF(), readUTF(), readBoolean()) }, + ) + } + + private const val MAGIC = 0x53425832 private const val TYPE_LAUNCH_PHONE = 1 - private const val TYPE_START_MEASUREMENT = 2 - private const val TYPE_STOP_MEASUREMENT = 3 - private const val TYPE_SYNC_MEASUREMENTS = 4 - private const val TYPE_REQUEST_SENSOR_LIST = 5 - private const val TYPE_SENSOR_LIST = 6 - 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 TYPE_SYNC_MEASUREMENTS = 2 + private const val TYPE_REQUEST_SENSOR_LIST = 3 + private const val TYPE_SENSOR_LIST = 4 + private const val TYPE_PREPARE_RECORDING = 10 + private const val TYPE_COMMIT_RECORDING = 11 + private const val TYPE_ABORT_RECORDING = 12 + private const val TYPE_STOP_RECORDING = 13 + private const val TYPE_ACKNOWLEDGEMENT = 14 +} + +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 + +private fun WearCommand.isValid(): Boolean = when (this) { + WearCommand.LaunchPhone, + WearCommand.RequestSensorList, + WearCommand.SyncMeasurements, + -> true + + is WearCommand.SensorList -> sensors.size <= MAX_SENSORS && sensors.all(WearSensorInfo::isValid) + + is WearCommand.PrepareRecording -> isValid() + + is WearCommand.CommitRecording -> validSessionId(sessionId) && startAtEpochMillis >= 0L + + is WearCommand.AbortRecording -> validSessionId(sessionId) + + is WearCommand.StopRecording -> validSessionId(sessionId) + + is WearCommand.Acknowledgement -> isValid() +} + +private fun WearSensorInfo.isValid(): Boolean = + name.length <= MAX_SENSOR_TEXT_LENGTH && vendor.length <= MAX_SENSOR_TEXT_LENGTH + +private fun WearCommand.PrepareRecording.isValid(): Boolean = validSessionId(sessionId) && + request.folderName.isNotBlank() && + request.folderName.length <= MAX_FOLDER_LENGTH && + request.sensorIds.size <= MAX_SENSORS && + request.durationMillis >= 0L && + request.measurementType.length <= MAX_TYPE_LENGTH + +private fun WearCommand.Acknowledgement.isValid(): Boolean = validSessionId(sessionId) && + failureCount >= 0 && + when (outcome) { + WearAcknowledgementOutcome.SUCCEEDED -> errorCode == null && failureCount == 0 + + WearAcknowledgementOutcome.REJECTED, + WearAcknowledgementOutcome.FAILED, + -> errorCode != null + } + +private fun validSessionId(sessionId: String): Boolean = sessionId.isNotBlank() && + sessionId.length <= MAX_SESSION_ID_LENGTH && + sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' } + +private inline fun > DataInputStream.readNamedEnum(values: List): T { + val name = readUTF() + return values.firstOrNull { it.name == name } ?: error("Unknown Wear protocol enum value") } 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 e465931..7c807d3 100644 --- a/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt +++ b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt @@ -1,48 +1,79 @@ package com.motionapps.wearoslib.protocol +import com.motionapps.sensorbox.core.error.AppErrorCode import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class WearCommandCodecTest { @Test - fun `Given a measurement command When encoded and decoded Then all fields survive`() { - val given = WearCommand.StartMeasurement("session", listOf(1, 4, 21), includesGps = true) - - val actual = WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()) + fun `Given protocol v2 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.PrepareRecording("session-123", request), + WearCommand.CommitRecording("session-123", 1_800_000_000_000L), + WearCommand.AbortRecording("session-123"), + WearCommand.StopRecording("session-123", WearStopReason.LOW_BATTERY), + WearCommand.Acknowledgement( + sessionId = "session-123", + command = WearSessionCommand.PREPARE, + outcome = WearAcknowledgementOutcome.SUCCEEDED, + ), + WearCommand.Acknowledgement( + sessionId = "session-123", + command = WearSessionCommand.STOP, + outcome = WearAcknowledgementOutcome.FAILED, + errorCode = AppErrorCode.MEASUREMENT, + failureCount = 2, + ), + ) - assertEquals(given, actual.getOrThrow()) + commands.forEach { command -> + val payload = WearCommandCodec.encode(command).getOrThrow() + assertEquals(command, WearCommandCodec.decode(payload).getOrThrow()) + } } @Test - fun `Given an unknown payload When decoded Then it is rejected`() { - val invalidPayload = byteArrayOf(1, 2, 3) + fun `Given a protocol v1 header When decoded Then it is rejected`() { + val v1Payload = byteArrayOf(0x53, 0x42, 0x58, 0x31, 0x01, 0x01) - val actual = WearCommandCodec.decode(invalidPayload) + assertTrue(WearCommandCodec.decode(v1Payload).isFailure) + } - assertTrue(actual.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 synchronized measurement When encoded Then schedule survives`() { - val given = WearCommand.StartMeasurement( - folderName = "shared_session", - sensorIds = listOf(1, 21), - includesGps = false, - startAtEpochMillis = 1_800_000_000_000L, - durationMillis = 45_000L, - measurementType = "TIMED", + fun `Given a successful acknowledgement with an error When encoded Then it is rejected`() { + val invalid = WearCommand.Acknowledgement( + sessionId = "session-123", + command = WearSessionCommand.COMMIT, + outcome = WearAcknowledgementOutcome.SUCCEEDED, + errorCode = AppErrorCode.MEASUREMENT, ) - assertEquals(given, WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()).getOrThrow()) + assertTrue(WearCommandCodec.encode(invalid).isFailure) } @Test - fun `Given a Wear sensor catalogue When encoded and decoded Then descriptors survive`() { - val given = WearCommand.SensorList( - listOf(WearSensorInfo(21, "Heart rate", "Fixture", isHeartRate = true)), - ) + fun `Given a malformed session identifier When encoded Then it is rejected`() { + val invalid = WearCommand.AbortRecording("session/with/private/path") - assertEquals(given, WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()).getOrThrow()) + assertTrue(WearCommandCodec.encode(invalid).isFailure) } } diff --git a/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt b/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt index 6a2025e..bfb62f3 100644 --- a/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt +++ b/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt @@ -1,5 +1,8 @@ package com.motionapps.wearoslib.connectivity +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.AppError + import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -7,7 +10,7 @@ class FakeWearConnectionRepository( initialConnection: WearConnection = WearConnection.Disconnected, ) : WearConnectionRepository { private val connection = MutableStateFlow(initialConnection) - private var sendResult: Result = Result.success(Unit) + private var sendResult: AppResult = AppResult.success(Unit) val sentMessages = mutableListOf() @@ -20,7 +23,7 @@ class FakeWearConnectionRepository( capability: String, path: String, payload: ByteArray, - ): Result { + ): AppResult { sentMessages += SentWearMessage(capability, path, payload.copyOf()) return sendResult } @@ -29,8 +32,8 @@ class FakeWearConnectionRepository( connection.value = newConnection } - fun failSending(error: Throwable) { - sendResult = Result.failure(error) + fun failSending(error: AppError) { + sendResult = AppResult.failure(error) } } 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 new file mode 100644 index 0000000..fc46960 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt @@ -0,0 +1,161 @@ +package com.motionapps.sensorbox.core.error + +import android.content.Context +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class DiagnosticMetadata( + val appVersion: String, + val buildType: String, + val deviceModel: String, + val androidVersion: String, + val processName: String, +) + +class FileDiagnostics internal constructor( + private val diagnosticsDirectory: File, + private val metadata: DiagnosticMetadata, +) : DiagnosticLogger, + DiagnosticsStore { + private val lock = Any() + + constructor(context: Context, metadata: DiagnosticMetadata) : this( + diagnosticsDirectory = File(context.filesDir, DIRECTORY_NAME), + metadata = metadata, + ) + + @Volatile + private var uncaughtHandlerInstalled = false + + override fun record(event: DiagnosticEvent) { + val entry = event.toDiagnosticEntry().take(MAX_ENTRY_CHARS) + synchronized(lock) { + appendSafely(entry) + } + } + + fun installUncaughtExceptionHandler() { + if (uncaughtHandlerInstalled) return + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + record( + DiagnosticEvent( + severity = DiagnosticSeverity.FATAL, + code = AppErrorCode.UNKNOWN, + operation = "Uncaught exception", + diagnosticMessage = "Uncaught ${error::class.java.simpleName}", + cause = error, + context = mapOf("threadName" to thread.name), + ), + ) + previous?.uncaughtException(thread, error) + } + uncaughtHandlerInstalled = true + } + + override fun readText(): AppResult = appResult(AppErrorCode.STORAGE, "Read diagnostics") { + synchronized(lock) { + val files = listOf(previousFile(), currentFile()).filter(File::exists) + if (files.isEmpty()) NO_DIAGNOSTICS else files.joinToString(separator = "") { it.readText() } + } + } + + override fun exportFile(): AppResult = readText().flatMap { text -> + appResult(AppErrorCode.STORAGE, "Export diagnostics") { + synchronized(lock) { + diagnosticsDirectory.mkdirs() + exportFilePath().apply { writeText(text) } + } + } + } + + override fun clear(): AppResult = appResult(AppErrorCode.STORAGE, "Clear diagnostics") { + synchronized(lock) { + listOf(currentFile(), previousFile(), exportFilePath()).forEach { file -> + check(!file.exists() || file.delete()) { "Unable to delete diagnostics" } + } + } + } + + private fun appendSafely(entry: String) { + try { + diagnosticsDirectory.mkdirs() + val file = currentFile() + if (file.length() + entry.toByteArray().size > MAX_FILE_BYTES) rotate(file) + file.appendText(entry) + } catch (_: Throwable) { + // A diagnostics write must never become an application failure. + } + } + + private fun rotate(file: File) { + val previous = previousFile() + if (previous.exists()) previous.delete() + if (file.exists()) file.renameTo(previous) + } + + private fun DiagnosticEvent.toDiagnosticEntry(): String = buildString { + val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date()) + append(timestamp).append(" | ").append(severity).append(" | ").append(code).append(" | ") + .append(operation.safeText()).appendLine() + append("message=").append(diagnosticMessage.safeText()).appendLine() + append("appVersion=").append(metadata.appVersion.safeText()).appendLine() + append("buildType=").append(metadata.buildType.safeText()).appendLine() + append("deviceModel=").append(metadata.deviceModel.safeText()).appendLine() + append("androidVersion=").append(metadata.androidVersion.safeText()).appendLine() + append("process=").append(metadata.processName.safeText()).appendLine() + context.filterKeys(SAFE_CONTEXT_KEYS::contains).toSortedMap().forEach { (key, value) -> + append(key).append('=').append(value.safeText()).appendLine() + } + cause?.let { error -> + append("exception=").append(error::class.java.name).appendLine() + error.stackTrace.take(MAX_STACK_FRAMES).forEach { frame -> + append("at ").append(frame.className).append('.').append(frame.methodName) + .append('(').append(frame.fileName?.substringAfterLast('/')?.safeText() ?: "Unknown") + .append(':').append(frame.lineNumber).appendLine(")") + } + } + appendLine(ENTRY_SEPARATOR) + } + + private fun String.safeText(): String = replace('\n', ' ').replace('\r', ' ').take(MAX_FIELD_CHARS) + + private fun currentFile(): File = File(diagnosticsDirectory, FILE_NAME) + + private fun previousFile(): File = File(diagnosticsDirectory, PREVIOUS_FILE_NAME) + + private fun exportFilePath(): File = File(diagnosticsDirectory, EXPORT_FILE_NAME) + + private companion object { + const val DIRECTORY_NAME = "diagnostics" + const val FILE_NAME = "sensorbox-diagnostics.txt" + const val PREVIOUS_FILE_NAME = "sensorbox-diagnostics-previous.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 + const val MAX_FIELD_CHARS = 512 + const val MAX_STACK_FRAMES = 80 + const val MAX_FILE_BYTES = 1_000_000L + val SAFE_CONTEXT_KEYS = setOf( + "androidVersion", + "buildType", + "durationMillis", + "failureCount", + "failedOperation", + "processName", + "protocolVersion", + "retryCount", + "samplingPeriod", + "sessionId", + "sourceCount", + "sourceType", + "state", + "stopReason", + "threadName", + ) + } +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt index 5ab2def..a711f73 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt @@ -1,9 +1,10 @@ package com.motionapps.sensorbox.core.preferences +import com.motionapps.sensorbox.core.error.AppResult import kotlinx.coroutines.flow.Flow interface AppPreferencesRepository { - val preferences: Flow> + val preferences: Flow> - suspend fun dispatch(intent: AppPreferencesIntent): Result + suspend fun dispatch(intent: AppPreferencesIntent): AppResult } diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt index aecb395..0814460 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt @@ -6,6 +6,8 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.suspendAppResult import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow @@ -13,15 +15,15 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map class DataStoreAppPreferencesRepository(private val dataStore: DataStore) : AppPreferencesRepository { - override val preferences: Flow> = dataStore.data - .map { values -> Result.success(values.toAppPreferences()) } + override val preferences: Flow> = dataStore.data + .map { values -> AppResult.success(values.toAppPreferences()) } .catch { error -> if (error is CancellationException) throw error - emit(Result.failure(AppError.from(AppError.Kind.PREFERENCES, "Read preferences", error))) + emit(AppResult.failure(AppError.from(AppErrorCode.PREFERENCES, "Read preferences", error))) } - override suspend fun dispatch(intent: AppPreferencesIntent): Result = suspendAppResult( - AppError.Kind.PREFERENCES, + override suspend fun dispatch(intent: AppPreferencesIntent): AppResult = suspendAppResult( + AppErrorCode.PREFERENCES, "Update preferences", ) { dataStore.edit { values -> diff --git a/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt b/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt index 9557d42..eaa69e1 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt @@ -4,18 +4,32 @@ import android.content.Context import android.content.Intent import androidx.documentfile.provider.DocumentFile import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import java.io.InputStream import java.io.OutputStream object NativeDocumentStorage { - fun persistRootAccess(context: Context, intent: Intent, appDirectoryName: String): Result { + fun persistRootAccess(context: Context, intent: Intent, appDirectoryName: String): AppResult { val uri = intent.data ?: return storageFailure("Storage directory was not selected") val grantFlags = intent.flags and READ_WRITE_FLAGS if (grantFlags == 0) return storageFailure("Storage permission was not granted") - return appResult(AppError.Kind.STORAGE, "Persist storage permission") { - context.contentResolver.takePersistableUriPermission(uri, grantFlags) + return appResult(AppErrorCode.STORAGE, "Persist storage permission") { + when (grantFlags) { + Intent.FLAG_GRANT_READ_URI_PERMISSION -> context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + + Intent.FLAG_GRANT_WRITE_URI_PERMISSION -> context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + + else -> context.contentResolver.takePersistableUriPermission(uri, READ_WRITE_FLAGS) + } DocumentFile.fromTreeUri(context, uri) }.flatMap { selectedDirectory -> if (selectedDirectory?.isDirectory != true) { @@ -25,38 +39,41 @@ object NativeDocumentStorage { if (appDirectory(context, appDirectoryName, create = true) == null) { storageFailure("Storage directory is unavailable") } else { - Result.success(Unit) + AppResult.success(Unit) } } } - fun hasAppDirectory(context: Context, appDirectoryName: String): Result = appResult( - AppError.Kind.STORAGE, + fun hasAppDirectory(context: Context, appDirectoryName: String): AppResult = appResult( + AppErrorCode.STORAGE, "Check storage directory", ) { appDirectory(context, appDirectoryName, create = false)?.exists() == true } - fun displayPath(context: Context, appDirectoryName: String): Result = appResult( - AppError.Kind.STORAGE, + fun displayPath(context: Context, appDirectoryName: String): AppResult = appResult( + AppErrorCode.STORAGE, "Read storage path", ) { val selectedDirectory = appDirectory(context, appDirectoryName, create = false) ?: return@appResult null selectedDirectory.name ?: selectedDirectory.uri.lastPathSegment } - fun createMeasurementDirectory(context: Context, appDirectoryName: String, measurementName: String): Result = - appResult(AppError.Kind.STORAGE, "Access measurement root") { - appDirectory(context, appDirectoryName, create = false) - }.flatMap { appDirectory -> - if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") - appResult(AppError.Kind.STORAGE, "Create measurement directory") { - findDirectory(appDirectory, measurementName) != null || - appDirectory.createDirectory(measurementName) != null - }.flatMap { created -> - if (created) Result.success(Unit) else storageFailure("Unable to create measurement directory") - } + fun createMeasurementDirectory( + context: Context, + appDirectoryName: String, + measurementName: String, + ): AppResult = appResult(AppErrorCode.STORAGE, "Access measurement root") { + appDirectory(context, appDirectoryName, create = false) + }.flatMap { appDirectory -> + if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") + appResult(AppErrorCode.STORAGE, "Create measurement directory") { + findDirectory(appDirectory, measurementName) != null || + appDirectory.createDirectory(measurementName) != null + }.flatMap { created -> + if (created) AppResult.success(Unit) else storageFailure("Unable to create measurement directory") } + } fun openMeasurementFile( context: Context, @@ -65,29 +82,29 @@ object NativeDocumentStorage { mimeType: String, fileName: String, replaceExisting: Boolean = false, - ): Result = appResult(AppError.Kind.STORAGE, "Access measurement directory") { + ): AppResult = appResult(AppErrorCode.STORAGE, "Access measurement directory") { measurementDirectory(context, appDirectoryName, measurementName) }.flatMap { directory -> if (directory == null) return@flatMap storageFailure("Measurement directory is unavailable") createOrReplaceFile(directory, mimeType, fileName, replaceExisting).flatMap { createdFile -> if (createdFile == null) return@flatMap storageFailure("Unable to create measurement file") - appResult(AppError.Kind.STORAGE, "Open measurement file") { + appResult(AppErrorCode.STORAGE, "Open measurement file") { context.contentResolver.openOutputStream(createdFile.uri, "wt") }.flatMap { output -> - output?.let(Result.Companion::success) ?: storageFailure("Unable to open measurement file") + output?.let(AppResult.Companion::success) ?: storageFailure("Unable to open measurement file") } } } - fun deleteMeasurement(context: Context, appDirectoryName: String, measurementName: String): Result = - appResult(AppError.Kind.STORAGE, "Access measurement directory") { + fun deleteMeasurement(context: Context, appDirectoryName: String, measurementName: String): AppResult = + appResult(AppErrorCode.STORAGE, "Access measurement directory") { appDirectory(context, appDirectoryName, create = false) }.flatMap { appDirectory -> if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") val directory = findDirectory(appDirectory, measurementName) ?: return@flatMap storageFailure("Measurement does not exist") - appResult(AppError.Kind.STORAGE, "Delete measurement") { directory.delete() }.flatMap { deleted -> - if (deleted) Result.success(Unit) else storageFailure("Unable to delete measurement") + appResult(AppErrorCode.STORAGE, "Delete measurement") { directory.delete() }.flatMap { deleted -> + if (deleted) AppResult.success(Unit) else storageFailure("Unable to delete measurement") } } @@ -98,7 +115,7 @@ object NativeDocumentStorage { measurementName: String, fileName: String, mimeType: String, - ): Result = openMeasurementFile( + ): AppResult = openMeasurementFile( context = context, appDirectoryName = appDirectoryName, measurementName = measurementName, @@ -106,7 +123,7 @@ object NativeDocumentStorage { fileName = fileName, replaceExisting = true, ).flatMap { output -> - appResult(AppError.Kind.STORAGE, "Copy measurement file") { + appResult(AppErrorCode.STORAGE, "Copy measurement file") { input.use { source -> output.use(source::copyTo) } Unit } @@ -148,7 +165,7 @@ private fun createOrReplaceFile( mimeType: String, fileName: String, replaceExisting: Boolean, -): Result = appResult(AppError.Kind.STORAGE, "Create measurement file") { +): AppResult = appResult(AppErrorCode.STORAGE, "Create measurement file") { val existing = directory.findFile(fileName) if (replaceExisting && existing != null) { if (existing.delete()) directory.createFile(normalizeMimeType(mimeType), fileName) else null @@ -157,8 +174,8 @@ private fun createOrReplaceFile( } } -private fun storageFailure(operation: String): Result = - Result.failure(AppError(AppError.Kind.STORAGE, operation)) +private fun storageFailure(operation: String): AppResult = + AppResult.failure(AppError(AppErrorCode.STORAGE, operation)) private fun releaseOtherRootPermissions(context: Context, selectedUri: android.net.Uri) { val resolver = context.contentResolver @@ -169,7 +186,7 @@ private fun releaseOtherRootPermissions(context: Context, selectedUri: android.n (if (permission.isReadPermission) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0) or (if (permission.isWritePermission) Intent.FLAG_GRANT_WRITE_URI_PERMISSION else 0) if (flags != 0) { - appResult(AppError.Kind.STORAGE, "Release old storage permission") { + appResult(AppErrorCode.STORAGE, "Release old storage permission") { resolver.releasePersistableUriPermission(permission.uri, flags) } } diff --git a/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt index 6623341..45c3c0e 100644 --- a/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt +++ b/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt @@ -12,11 +12,11 @@ class AppErrorTest { fun `Given an operation failure When captured Then AppError is returned`() { val cause = IllegalStateException("disk unavailable") - val result = appResult(AppError.Kind.STORAGE, "Write file") { throw cause } + val result = appResult(AppErrorCode.STORAGE, "Write file") { throw cause } - val error = result.exceptionOrNull() + val error = result.errorOrNull() assertTrue(error is AppError) - assertEquals(AppError.Kind.STORAGE, (error as AppError).kind) + assertEquals(AppErrorCode.STORAGE, (error as AppError).code) assertEquals("Write file", error.operation) assertSame(cause, error.cause) } @@ -24,7 +24,7 @@ class AppErrorTest { @Test(expected = CancellationException::class) fun `Given coroutine cancellation When captured Then cancellation is rethrown`() { runBlocking { - suspendAppResult(AppError.Kind.CONNECTIVITY, "Send message") { + suspendAppResult(AppErrorCode.CONNECTIVITY, "Send message") { throw CancellationException("cancelled") } } diff --git a/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt new file mode 100644 index 0000000..b14462f --- /dev/null +++ b/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt @@ -0,0 +1,95 @@ +package com.motionapps.sensorbox.core.error + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class FileDiagnosticsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `Given many events When logs rotate Then only two one megabyte files remain`() { + val directory = temporaryFolder.newFolder("diagnostics") + val diagnostics = diagnostics(directory) + + repeat(2_500) { index -> diagnostics.record(event(message = "$index ${"x".repeat(1_000)}")) } + + val retainedLogs = directory.listFiles().orEmpty().filter { "export" !in it.name } + assertEquals(2, retainedLogs.size) + assertTrue(retainedLogs.all { it.length() <= 1_000_000L }) + assertTrue(diagnostics.readText().getOrNull().orEmpty().contains("2499 ")) + } + + @Test + fun `Given private context When event is recorded Then only allowlisted context is written`() { + val diagnostics = diagnostics(temporaryFolder.newFolder("diagnostics")) + diagnostics.record( + event( + message = "Controlled failure message", + context = mapOf( + "sessionId" to "session-123", + "sensorSamples" to "1.2,3.4,5.6", + "coordinates" to "48.1,17.1", + "annotation" to "private note", + "recordingName" to "private recording", + "safUri" to "content://private/path", + "rawPayload" to "secret bytes", + "deviceId" to "unique-device-id", + ), + ), + ) + + val text = diagnostics.readText().getOrNull().orEmpty() + assertTrue(text.contains("sessionId=session-123")) + assertFalse(text.contains("1.2,3.4,5.6")) + assertFalse(text.contains("48.1,17.1")) + assertFalse(text.contains("private note")) + assertFalse(text.contains("private recording")) + assertFalse(text.contains("content://private/path")) + assertFalse(text.contains("secret bytes")) + assertFalse(text.contains("unique-device-id")) + } + + @Test + fun `Given an unwritable location When event is recorded Then logger does not throw`() { + val fileInsteadOfDirectory = temporaryFolder.newFile("not-a-directory") + val diagnostics = diagnostics(fileInsteadOfDirectory) + + diagnostics.record(event(message = "Write failure")) + + assertTrue(fileInsteadOfDirectory.isFile) + } + + @Test + fun `Given retained logs When cleared Then reading returns the empty message`() { + val diagnostics = diagnostics(temporaryFolder.newFolder("diagnostics")) + diagnostics.record(event(message = "Before clear")) + + assertTrue(diagnostics.clear().isSuccess) + + assertEquals("No diagnostics have been recorded.\n", diagnostics.readText().getOrNull()) + } + + private fun diagnostics(directory: java.io.File) = FileDiagnostics( + diagnosticsDirectory = directory, + metadata = DiagnosticMetadata( + appVersion = "1.0", + buildType = "test", + deviceModel = "test model", + androidVersion = "test android", + processName = "test process", + ), + ) + + private fun event(message: String, context: Map = emptyMap()) = DiagnosticEvent( + severity = DiagnosticSeverity.ERROR, + code = AppErrorCode.STORAGE, + operation = "Test operation", + diagnosticMessage = message, + context = context, + ) +} diff --git a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt index c6ce1eb..ede7036 100644 --- a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt +++ b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt @@ -1,5 +1,7 @@ package com.motionapps.sensorbox.core.testing +import com.motionapps.sensorbox.core.error.AppResult + import com.motionapps.sensorbox.core.preferences.AppPreferences import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent import com.motionapps.sensorbox.core.preferences.AppPreferencesReducer @@ -12,10 +14,10 @@ class FakeAppPreferencesRepository( ) : AppPreferencesRepository { private val mutablePreferences = MutableStateFlow(initial) - override val preferences = mutablePreferences.map(Result.Companion::success) + override val preferences = mutablePreferences.map(AppResult.Companion::success) - override suspend fun dispatch(intent: AppPreferencesIntent): Result { + override suspend fun dispatch(intent: AppPreferencesIntent): AppResult { mutablePreferences.value = AppPreferencesReducer.reduce(mutablePreferences.value, intent) - return Result.success(Unit) + return AppResult.success(Unit) } }