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
45 changes: 33 additions & 12 deletions AGENTS.MD
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ Every screen is its own module and cross-cutting concerns are in their own modul
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.
UI is dumb and only renders the state. The root composable delegates intents and performs
native side effects. It can use other classes to do so.

## Testing

Expand All@@ -42,20 +42,41 @@ The tests should be simple and readable, ideally only one line per statement in

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.
## Glossary

### Recording domain

- Recording - the activity of capturing data.
- Recording session - one start-to-stop recording operation. It may run on the phone alone or on both phone and watch.
- Recording setup - editable choices made before recording.
- Recording request - the immutable command created from the setup when recording starts.
- Recording source - an independently managed data producer, such as sensors or GPS. One source may create several files.
- Paired recording - one session coordinated across phone and watch.
- Measurement - one device's stored output for a recording session. A metadata-only measurement is valid.
- Measurement file - one stored data stream within a measurement.
- Measurement metadata - facts about the whole measurement, not a user-visible recording source.
- Sample - one timestamped observation, normally stored as one row.
- Recording archive - the selected phone directory containing phone and synced watch measurements.
- Sync - copying watch measurements to the phone archive without merging or removing the watch originals.
- Sensor - a sensor exposed by the phone or watch. Sensor type names its kind, such as accelerometer.
- Use phone and watch in domain code. Reserve Android and Wear OS for platform code.

### MVI

- Screen - stateless UI that renders state and sends intents.
- Intent - something the UI asks the ViewModel to handle. Do not use Action as a synonym.
- State - immutable data needed to render a screen.
- Effect - a one-time operation outside state rendering, such as navigation or requesting permission.
- ViewModel - owns state, handles intents, and coordinates domain operations.
- Reducer - a pure state transition.
- Contract - the state, intents, effects, and reducer used by a screen.
- Repo - a boundary for reading or changing stored data or platform state.
- Use case - one named domain operation or user goal.

## 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.
Be straight and go to the point.
23 changes: 0 additions & 23 deletions CONTEXT.md

This file was deleted.

20 changes: 10 additions & 10 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,9 +6,9 @@ This is the hard-cut Android 17 generation of the project. It does not retain th

## Screenshots

| Sensor selection | Measurement setup |
| Sensor selection | Recording setup |
|:---:|:---:|
| <img src="docs/images/sensorbox-phone-record.png" alt="SensorBox source selection" width="280"> | <img src="docs/images/sensorbox-phone-setup.png" alt="SensorBox measurement setup" width="280"> |
| <img src="docs/images/sensorbox-phone-record.png" alt="SensorBox source selection" width="280"> | <img src="docs/images/sensorbox-phone-setup.png" alt="SensorBox recording setup" width="280"> |

| Wear dashboard | Wear live-sensor picker |
|:---:|:---:|
Expand All@@ -20,11 +20,11 @@ This is the hard-cut Android 17 generation of the project. It does not retain th
|:---:|:---:|:---:|
| <img src="docs/images/sensorbox-intro-welcome.png" alt="SensorBox welcome introduction" width="220"> | <img src="docs/images/sensorbox-intro-privacy.png" alt="SensorBox local-data introduction" width="220"> | <img src="docs/images/sensorbox-intro-policy.png" alt="SensorBox privacy and terms introduction" width="220"> |

| Android lifecycle | Battery optimization | Recording folder |
| Android lifecycle | Battery optimization | Recording archive |
|:---:|:---:|:---:|
| <img src="docs/images/sensorbox-intro-lifecycle.png" alt="SensorBox Android lifecycle introduction" width="220"> | <img src="docs/images/sensorbox-intro-battery.png" alt="SensorBox battery optimization introduction" width="220"> | <img src="docs/images/sensorbox-intro-storage.png" alt="SensorBox recording-folder introduction" width="220"> |

The introduction uses tintable vector illustrations that follow the app theme. Privacy Policy, Terms of Use, battery optimization, and folder selection are live native actions. Folder selection remains mandatory before setup can finish.
The introduction uses tintable vector illustrations that follow the app theme. Privacy Policy, Terms of Use, battery optimization, and recording-archive selection use native Android screens. A recording archive remains mandatory before setup can finish.

All screenshots above come from deterministic Compose preview fixtures. Refresh the complete gallery on the host without an emulator or connected device:

Expand All@@ -36,11 +36,11 @@ All screenshots above come from deterministic Compose preview fixtures. Refresh

- Record available phone or watch sensors at Android sampling periods.
- Record foreground GPS samples alongside sensor data.
- Run measurement work in an explicit foreground service with health/location service types.
- Run recording work in an explicit foreground service with health/location service types.
- Stop safely from the app, watch, notification, low-battery policy, or a paired-device command.
- Preview a live watch sensor with a Compose-native chart.
- Stream watch recordings to the phone with the Wear OS Channel API.
- Store phone recordings in a user-selected Storage Access Framework folder.
- Store phone measurements in a user-selected recording archive through the Storage Access Framework.
- Follow system/dynamic color with light, dark, and custom fallback palettes.

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

## Emulator integration tests

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:
The phone recording tests start the real foreground recording 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_SERIAL=emulator-5554 ./gradlew :app:connectedDebugAndroidTest \
"-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.PhoneSensorRecordingEmulatorTest"
```

Standalone Wear recording tests use the watch sensors and control test GPS and battery state from Kotlin. They do not require a paired phone:
Standalone watch recording tests use the watch sensors and control test GPS and battery state from Kotlin. They do not require a paired phone:

```shell
ANDROID_SERIAL=emulator-5554 ./gradlew :wear:connectedDebugAndroidTest \
Expand All@@ -116,7 +116,7 @@ The paired sync matrix sends CSV, JSON, text, empty, Unicode, overwrite, duplica
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.
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 recording archive.

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

Expand All@@ -128,7 +128,7 @@ The former Flipper, AppIntro, Material Dialogs, NumberPicker, Android About Page

## Privacy

Measurements are initiated by the user, represented by an ongoing foreground-service notification, and written locally. SensorBox does not upload measurement data or include analytics/crash-reporting SDKs.
Recordings are started by the user and remain visible through a foreground-service notification. SensorBox writes each device's measurement locally and does not include upload, analytics, or crash-reporting SDKs.

## License

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext c
is AppResult.Success -> opened.value.use(consume)
}
val close = closeChannel(channel)
listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Receive Wear file")
listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Receive watch file")
}

private suspend fun openChannel(nodeId: String, path: String): AppResult<ChannelClient.Channel> =
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
package com.tomasrepcik.sensorbox.wearoslib.protocol

import com.tomasrepcik.sensorbox.core.error.AppErrorCode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
Expand All@@ -26,7 +27,8 @@ sealed interface WearCommand {
@Serializable
data class RecordingResult(
val sessionId: String,
val action: WearRecordingAction,
@SerialName("action")
val operation: WearRecordingOperation,
val outcome: WearRecordingOutcome,
@Serializable(with = AppErrorCodeNameSerializer::class)
val errorCode: AppErrorCode? = null,
Expand All@@ -46,7 +48,7 @@ data class WearRecordingRequest(
)

@Serializable
enum class WearRecordingAction {
enum class WearRecordingOperation {
START,
STOP,
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ internal object AppErrorCodeNameSerializer : KSerializer<AppErrorCode> {

override fun deserialize(decoder: Decoder): AppErrorCode {
val name = decoder.decodeString()
if (name == "MEASUREMENT") return AppErrorCode.RECORDING
return AppErrorCode.entries.firstOrNull { it.name == name }
?: error("Unknown application error code")
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,16 +40,16 @@ class WearCommandCodecTest {
WearCommand.StopRecording("session-123", WearStopReason.LOW_BATTERY),
WearCommand.RecordingResult(
sessionId = "session-123",
action = WearRecordingAction.START,
operation = WearRecordingOperation.START,
outcome = WearRecordingOutcome.SUCCEEDED,
),
WearCommand.RecordingResult(
sessionId = "session-123",
action = WearRecordingAction.STOP,
operation = WearRecordingOperation.STOP,
outcome = WearRecordingOutcome.FAILED,
errorCode = AppErrorCode.MEASUREMENT,
errorOperation = "Stop Wear recording",
errorMessage = "Wear recording service could not stop",
errorCode = AppErrorCode.RECORDING,
errorOperation = "Stop watch recording",
errorMessage = "watch recording service could not stop",
errorContext = mapOf("serviceState" to "stopping"),
failureCount = 2,
),
Expand DownExpand Up@@ -79,9 +79,9 @@ class WearCommandCodecTest {
fun `Given a successful result with an error When encoded Then it is rejected`() {
val invalid = WearCommand.RecordingResult(
sessionId = "session-123",
action = WearRecordingAction.START,
operation = WearRecordingOperation.START,
outcome = WearRecordingOutcome.SUCCEEDED,
errorCode = AppErrorCode.MEASUREMENT,
errorCode = AppErrorCode.RECORDING,
errorOperation = "Unexpected failure",
errorMessage = "A successful result cannot contain an error",
)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ class PhoneSensorRecordingEmulatorTest {
}

@Test
fun givenSignificantMotionWhenStoppedThenThePreparedFileCloses() {
fun givenSignificantMotionRecordingWhenStoppedThenThePreparedFileCloses() {
fixture.start(
RecordingScenario(
name = "PHONE_SIGNIFICANT_MOTION_FAILURE_TEST",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,7 @@ import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH
import com.tomasrepcik.sensorbox.wearoslib.connectivity.GooglePlayWearConnectionRepository
import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand
import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec
import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction
import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation
import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome
import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest
import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason
Expand DownExpand Up@@ -102,7 +102,7 @@ class PhoneWearDiscoveryEmulatorTest {
).getOrThrow()

val result = awaitCommand<WearCommand.RecordingResult>(commands) {
it.sessionId == sessionId && it.action == WearRecordingAction.START
it.sessionId == sessionId && it.operation == WearRecordingOperation.START
}
assertEquals(WearRecordingOutcome.SUCCEEDED, result.outcome)
} finally {
Expand DownExpand Up@@ -147,10 +147,10 @@ class PhoneWearDiscoveryEmulatorTest {
).getOrThrow()

val result = awaitCommand<WearCommand.RecordingResult>(commands) {
it.sessionId == sessionId && it.action == WearRecordingAction.START
it.sessionId == sessionId && it.operation == WearRecordingOperation.START
}
assertEquals(WearRecordingOutcome.FAILED, result.outcome)
assertEquals("Validate Wear recording permissions", result.errorOperation)
assertEquals("Validate watch recording permissions", result.errorOperation)
assertEquals("Wear OS is missing required recording permissions", result.errorMessage)
assertTrue(
result.errorContext["missingPermissions"]
Expand Down
Loading