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
61 changes: 61 additions & 0 deletions AGENTS.MD
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
# VoidLauncher

## Project

This project is a SensorBox app, which records raw Android and Wear OS sensor samples to local CSV files. The app uses Android's system folder picker, and recordings never require cloud storage or an account.

The intent of the project is to provide a simple and reliable way to record sensor data from Android and Wear OS devices, while maintaining user privacy and control over their data.


## Coding style

The simplicity is above all. The code should be simple, readable and verbose
enough that it is simple to understand. Methods and logic parts should be small enough to skim through
and have nuanced gaps between them.

Everything is named by the intent of the user, and only major contracts can have names
containing technical terms. Practically, the navigation in code is the navigation of the user.

The app uses MVI architecture - UI is dumb, calls the ViewModel, and it orchestrates data sources and APIs.
The ViewModel comes back with a state that is rendered by the UI. The state is immutable and contains all the data needed to render the UI.

Every screen is its own module and cross-cutting concerns are in their own modules.
The modules are small and have a single responsibility.
Every screen has root composable, where the viewmodel and UI meet ends.
The UI only talks to viewmodel, not repos. Repos and usecases are part of the viewmodels.
UI is dumb and only renders the state. Root is responsible for delegating actions, events and
native side effects - it can use other classes to do so.

## Testing

Every ViewModel and repo has its own tests on unit level.
Fakes are only permitted and mocks only for third party implemenations, which
would be hard to fake.

Tests follow the same coding style as the main code and BDD - descriptions are composed of
Given, When, Then and test contains these comments too as its id divided.

Fixtures are used to create conditions for test executiona and ideally should be reused
across tests.

The tests should be simple and readable, ideally only one line per statement in the description of the test.

Run only tests, which are required and at the end of large implementation, run the whole test suite.

## Terminology

- Screen / Root Screen - a screen is a module that contains all the code for a single screen. It has its own ViewModel, UI and repos. It is responsible for rendering the state and delegating actions to the ViewModel.
- State - a state is an immutable data class that contains all the data needed to render the UI. It is returned by the ViewModel and rendered by the UI.
- Action - an action is a user interaction that is delegated to the ViewModel. It can be a button click, a gesture, a keyboard shortcut, etc.
- User intent - a user intent is a high-level action that the user wants to perform. It is translated into one or more actions that are delegated to the ViewModel. Naming is based on user intent, not technical terms. For example, "Open App" is a user intent, while "LaunchActivity" is a technical term. Contract between layers can be merged with technical term.
- Contract - defines communication between layers. Techncial terms can be used for these classes too.
- Repo - a repo is a class that is responsible for fetching data from a data source. It can be a local database, a remote API, or any other data source. It is used by the ViewModel to fetch data and return it as a state.
- Usecase - a usecase is a class that contains business logic and orchestrates data from multiple repos. It is used by the ViewModel to perform complex actions and return a state. It equals to a user intention to do something.

## People

You are agent.
Me as user I am programmer, and we are building this project in collaboration.
Explain your ideas, ask questions, give suggestions and think about possible architectural improvements
based on the current state of the project. Review the code and give feedback.
Be straight and go to the point.
23 changes: 23 additions & 0 deletions CONTEXT.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
# Recording archive

The recording archive contains measurements created on the phone or copied from Wear OS.

## Language

**Measurement**

One recording session stored as a named folder in the selected recording directory.

_Avoid:_ Recording file, sensor measurement

**Measurement file**

One data stream inside a measurement, such as accelerometer samples or GPS coordinates.

_Avoid:_ Measurement

**Measurement metadata**

Session-level facts stored with a measurement, including its start time, notes, alarms, duration, and sensor ranges.

_Avoid:_ Sensor metadata
27 changes: 15 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,9 +59,9 @@ 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 v3 command encoding, and Channel file transport. App policy stays in `app` and `wear`.
- `WearOsLib`: coroutine-based connectivity, strict protocol v5 JSON commands, 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.
Paired phone/watch recording starts directly on each device. Commands are session-correlated and idempotent, and both devices own their local duration timer after starting. A lost connection does not stop an active recording; peer stop notifications are best effort.

## Platform and toolchain

Expand DownExpand Up@@ -96,24 +96,27 @@ Tests use Given/When/Then naming, reusable state/repository fixtures, coroutine

## Emulator integration tests

The phone sensor test starts the real foreground measurement service, injects three accelerometer values through the emulator console, and verifies the generated CSV:
The phone recording tests start the real foreground measurement service, read the device sensors, control test GPS and battery state from Kotlin, and verify the generated files. Run the class directly from Android Studio or with Gradle:

```shell
ANDROID_HOME="$HOME/Library/Android/sdk" \
PHONE_SERIAL=emulator-5554 \
tools/emulator/run_phone_sensor_test.sh
ANDROID_SERIAL=emulator-5554 ./gradlew :app:connectedDebugAndroidTest \
"-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.PhoneSensorRecordingEmulatorTest"
```

The Wear sync test sends a fixture CSV through the real Wear OS Channel API and verifies its exact bytes on the phone. Use an Android 17 Google Play phone AVD and a Wear OS 7 AVD. Pair them once with Android Studio's Pairing Assistant and complete the Wear companion flow before running:
Standalone Wear recording tests use the watch sensors and control test GPS and battery state from Kotlin. They do not require a paired phone:

```shell
ANDROID_HOME="$HOME/Library/Android/sdk" \
PHONE_SERIAL=emulator-5554 \
WEAR_SERIAL=emulator-5556 \
tools/emulator/run_wear_sync_test.sh
ANDROID_SERIAL=emulator-5554 ./gradlew :wear:connectedDebugAndroidTest \
"-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.WearSensorRecordingEmulatorTest"
```

The runner creates Android Studio's ADB forward/reverse bridge and fails immediately with pairing guidance when the watch reports no peer. Received files use app-internal storage only in debuggable builds; release builds continue to require the user-selected Storage Access Framework directory.
The paired sync matrix sends CSV, JSON, text, empty, Unicode, overwrite, duplicate-name, ignored-extension, and 256 KiB fixtures through the real Wear OS Channel API. The phone verifies every destination and byte. Use a Google Play phone AVD and a Wear OS AVD, then pair them once with Android Studio's Pairing Assistant:

```shell
ANDROID_HOME="$HOME/Library/Android/sdk" tools/emulator/run_wear_sync_test.sh
```

The runner detects one phone and one watch automatically; `PHONE_SERIAL` and `WEAR_SERIAL` remain available when several devices are connected. It builds and installs once, refreshes the ADB bridge after installation, and launches each scenario on both devices. When an emulator transport exposes its paired node but does not propagate static capabilities, the instrumentation-only repository falls back to that connected node; file transfer still uses the production Channel client and receiver. Received files use app-internal storage only in debuggable builds; release builds continue to require the user-selected Storage Access Framework directory.

No Firebase project, Maps key, secrets file, or external storage permission is required.

Expand Down
2 changes: 2 additions & 0 deletions WearOsLib/build.gradle.kts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.detekt)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
}

android {
Expand DownExpand Up@@ -47,6 +48,7 @@ dependencies {
implementation(libs.coroutines.core)
implementation(libs.coroutines.play.services)
implementation(libs.hilt.android)
implementation(libs.kotlinx.serialization.json)
ksp(libs.hilt.compiler)
testImplementation(libs.junit)
testFixturesImplementation(project(":core-common"))
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,6 @@ package com.tomasrepcik.sensorbox.wearoslib.connectivity

import android.content.Context
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.Node
import com.google.android.gms.wearable.Wearable
import com.tomasrepcik.sensorbox.core.error.AppError
Expand All@@ -12,9 +11,13 @@ import com.tomasrepcik.sensorbox.core.error.suspendAppResult
import com.tomasrepcik.sensorbox.core.error.suspendFlatMap
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
Expand All@@ -24,24 +27,35 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext
WearConnectionRepository {
private val capabilityClient = Wearable.getCapabilityClient(context)
private val messageClient = Wearable.getMessageClient(context)
private val nodeClient = Wearable.getNodeClient(context)

override fun observeCapability(capability: String): Flow<WearConnection> = callbackFlow {
val listener = CapabilityClient.OnCapabilityChangedListener { info ->
trySend(info.toConnection())
val capabilityListener = CapabilityClient.OnCapabilityChangedListener {
launch { trySend(loadConnection(capability)) }
}
capabilityClient.addListener(listener, capability).await()
capabilityClient.addListener(capabilityListener, capability).await()
trySend(loadConnection(capability))
awaitClose { capabilityClient.removeListener(listener) }
val nodePolling = launch {
while (isActive) {
delay(NODE_POLL_INTERVAL_MILLIS)
trySend(loadConnectedNodeConnection())
}
}
awaitClose {
nodePolling.cancel()
capabilityClient.removeListener(capabilityListener)
}
}.catch { error ->
AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error)
emit(WearConnection.Disconnected)
}
}.distinctUntilChanged()

override suspend fun findNode(capability: String): WearNode? {
val info = capabilityClient
.getCapability(capability, CapabilityClient.FILTER_REACHABLE)
.await()
return WearNodeSelector.select(info.nodes.map { it.toWearNode() })
?: WearNodeSelector.select(nodeClient.connectedNodes.await().map { it.toWearNode() })
}

override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult<Unit> =
Expand All@@ -61,8 +75,8 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext
?.let(WearConnection::Connected)
?: WearConnection.Disconnected

private fun CapabilityInfo.toConnection(): WearConnection = WearNodeSelector
.select(nodes.map { it.toWearNode() })
private suspend fun loadConnectedNodeConnection(): WearConnection = WearNodeSelector
.select(nodeClient.connectedNodes.await().map { it.toWearNode() })
?.let(WearConnection::Connected)
?: WearConnection.Disconnected

Expand All@@ -71,4 +85,8 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext
displayName = displayName,
isNearby = isNearby,
)

private companion object {
const val NODE_POLL_INTERVAL_MILLIS = 2_000L
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,6 @@ 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): AppResult<Unit> =
invoke(capability, path, message.encodeToByteArray())

suspend operator fun invoke(capability: String, path: String, payload: ByteArray): AppResult<Unit> =
repository.sendMessage(capability, path, payload)
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@ import com.tomasrepcik.sensorbox.core.error.AppErrorCode
import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.error.combineAppResults
import com.tomasrepcik.sensorbox.core.error.suspendAppResult
import com.tomasrepcik.sensorbox.core.error.suspendFlatMap
import com.tomasrepcik.sensorbox.core.error.withAppError
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
Expand All@@ -24,35 +23,52 @@ class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext c
metadata: WearFileMetadata,
input: () -> java.io.InputStream,
): AppResult<Unit> = withContext(Dispatchers.IO) {
WearFilePathCodec.encode(metadata).suspendFlatMap { path ->
suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear channel") {
channelClient.openChannel(nodeId, path).await()
}
}.suspendFlatMap { channel ->
val transfer = suspendAppResult(AppErrorCode.CONNECTIVITY, "Write Wear channel") {
input().use { source ->
channelClient.getOutputStream(channel).await().use(source::copyTo)
}
}
val close = suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") {
channelClient.close(channel).await()
}
listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Send Wear file")
}.withAppError(AppErrorCode.CONNECTIVITY, "Send Wear file")
val path = when (val encoded = WearFilePathCodec.encode(metadata)) {
is AppResult.Failure -> return@withContext encoded
is AppResult.Success -> encoded.value
}
val channel = when (val opened = openChannel(nodeId, path)) {
is AppResult.Failure -> return@withContext opened
is AppResult.Success -> opened.value
}
val transfer = writeChannel(channel, input)
val close = closeChannel(channel)
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) -> AppResult<Unit>,
): AppResult<Unit> = withContext(Dispatchers.IO) {
val transfer = suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear input stream") {
val opened = suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear input stream") {
channelClient.getInputStream(channel).await()
}.suspendFlatMap { input ->
input.use(consume)
}
val close = suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") {
channelClient.close(channel).await()
val transfer = when (opened) {
is AppResult.Failure -> opened
is AppResult.Success -> opened.value.use(consume)
}
val close = closeChannel(channel)
listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Receive Wear file")
}

private suspend fun openChannel(nodeId: String, path: String): AppResult<ChannelClient.Channel> =
suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear channel") {
channelClient.openChannel(nodeId, path).await()
}

private suspend fun writeChannel(
channel: ChannelClient.Channel,
input: () -> java.io.InputStream,
): AppResult<Unit> = suspendAppResult(AppErrorCode.CONNECTIVITY, "Write Wear channel") {
input().use { source ->
channelClient.getOutputStream(channel).await().use(source::copyTo)
}
}

private suspend fun closeChannel(channel: ChannelClient.Channel): AppResult<Unit> =
suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") {
channelClient.close(channel).await()
}
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
package com.tomasrepcik.sensorbox.wearoslib.files

import kotlinx.serialization.Serializable

@Serializable
data class WearFileMetadata(val measurementName: String, val fileName: String)
Loading