Skip to content
Draft
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
3 changes: 2 additions & 1 deletion WearOsLib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ android {
}

dependencies {
implementation(project(":core"))
implementation(project(":core-common"))

implementation(libs.androidx.core.ktx)
implementation(libs.play.services.wearable)
Expand All @@ -49,6 +49,7 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
testImplementation(libs.junit)
testFixturesImplementation(project(":core-common"))
testFixturesImplementation(libs.coroutines.core)
}

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/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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -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<Unit> =
suspendAppResult(AppError.Kind.CONNECTIVITY, "Find Wear node") { findNode(capability) }
override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult<Unit> =
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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package com.motionapps.wearoslib.connectivity

import com.motionapps.sensorbox.core.error.AppResult
import kotlinx.coroutines.flow.Flow

interface WearConnectionRepository {
fun observeCapability(capability: String): Flow<WearConnection>

suspend fun findNode(capability: String): WearNode?

suspend fun sendMessage(capability: String, path: String, payload: ByteArray): Result<Unit>
suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult<Unit>
}
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<Unit> =
suspend operator fun invoke(capability: String, path: String, message: String): AppResult<Unit> =
invoke(capability, path, message.encodeToByteArray())

suspend operator fun invoke(capability: String, path: String, payload: ByteArray): Result<Unit> =
suspend operator fun invoke(capability: String, path: String, payload: ByteArray): AppResult<Unit> =
repository.sendMessage(capability, path, payload)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,36 +23,36 @@ class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext c
nodeId: String,
metadata: WearFileMetadata,
input: () -> java.io.InputStream,
): Result<Unit> = withContext(Dispatchers.IO) {
): AppResult<Unit> = 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<Unit>,
): Result<Unit> = withContext(Dispatchers.IO) {
val transfer = suspendAppResult(AppError.Kind.CONNECTIVITY, "Open Wear input stream") {
consume: (java.io.InputStream) -> AppResult<Unit>,
): AppResult<Unit> = 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")
}
}
Original file line number Diff line number Diff line change
@@ -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<String> = if (
fun encode(metadata: WearFileMetadata): AppResult<String> = 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<WearFileMetadata> {
fun decode(path: String): AppResult<WearFileMetadata> {
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-_"
}
Original file line number Diff line number Diff line change
@@ -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<Unit>
suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): AppResult<Unit>

suspend fun receive(channel: ChannelClient.Channel, consume: (InputStream) -> Result<Unit>): Result<Unit>
suspend fun receive(channel: ChannelClient.Channel, consume: (InputStream) -> AppResult<Unit>): AppResult<Unit>
}
Original file line number Diff line number Diff line change
@@ -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<Unit> =
WearCommandCodec.encode(command).suspendFlatMap { payload ->
sendMessage(capability, path, payload)
}
}
Original file line number Diff line number Diff line change
@@ -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<Int>,
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<WearSensorInfo>) : 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<Int>,
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)
Loading