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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ build/
/app/*.json
/wear/*.json
/.codebase-memory/
.DS_Store
195 changes: 66 additions & 129 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,142 +1,79 @@
# SensorBox

SensorBox records raw Android and Wear OS sensor samples to local CSV files. The phone app uses Android's system folder picker; recordings never require cloud storage or an account.

This is the hard-cut Android 17 generation of the project. It does not retain the former Fragment/XML UI, `SharedPreferences`, Firebase, Maps, or compatibility migrations for old settings.

## Screenshots

| 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 recording setup" width="280"> |

| Wear dashboard | Wear live-sensor picker |
|:---:|:---:|
| <img src="docs/images/sensorbox-wear.png" alt="SensorBox Wear OS dashboard" width="280"> | <img src="docs/images/sensorbox-wear-live.png" alt="SensorBox Wear OS live-sensor picker" width="280"> |

### First-run introduction

| Welcome | Local data | Privacy and terms |
|:---:|:---:|:---:|
| <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 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 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:

```shell
./gradlew refreshReadmeScreenshots
<p align="center">
<img src="AppImages/icon.png" width="132" alt="SensorBox app icon">
</p>

<h1 align="center">SensorBox</h1>

<p align="center">
Raw phone and watch sensors, recorded to files you own.
</p>

<p align="center">
<img alt="Android 10+" src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&amp;logoColor=white">
<img alt="Wear OS" src="https://img.shields.io/badge/Wear%20OS-supported-4285F4?logo=wearos&amp;logoColor=white">
<img alt="Jetpack Compose" src="https://img.shields.io/badge/UI-Jetpack%20Compose-4285F4?logo=jetpackcompose&amp;logoColor=white">
</p>

## See it in use

<table>
<tr>
<td width="25%"><img src="docs/images/sensorbox-phone-record.png" alt="SensorBox phone sensor selection"></td>
<td width="25%"><img src="docs/images/sensorbox-phone-setup.png" alt="SensorBox recording setup"></td>
<td width="25%"><img src="docs/images/sensorbox-wear.png" alt="SensorBox Wear OS dashboard"></td>
<td width="25%"><img src="docs/images/sensorbox-wear-live.png" alt="SensorBox live watch sensor"></td>
</tr>
<tr>
<td align="center"><sub>Choose exactly what to capture.</sub></td>
<td align="center"><sub>Set timing and recording rules.</sub></td>
<td align="center"><sub>Record from the watch on its own.</sub></td>
<td align="center"><sub>See a live signal before committing.</sub></td>
</tr>
</table>

## The idea

SensorBox turns Android and Wear OS hardware into a field recorder. Pick the sensors, start a visible recording, and get plain CSV files in a folder you chose. No account, analytics SDK, or cloud storage sits in the middle.

## What it does

- Records available phone and watch sensors at Android sampling periods
- Adds foreground GPS samples when requested
- Runs through an explicit foreground service with safe stop paths
- Shows a live watch signal with a Compose chart
- Sends watch recordings to the phone through the Wear OS Channel API
- Writes phone measurements through Android's Storage Access Framework
- Keeps active recordings alive if the paired device disconnects

## How it is built

Phone and watch screens use MVI:

```text
Composable -> Intent -> ViewModel -> use case -> repository -> State + Effect
```

## Current feature set

- Record available phone or watch sensors at Android sampling periods.
- Record foreground GPS samples alongside sensor data.
- 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 measurements in a user-selected recording archive through the Storage Access Framework.
- Follow system/dynamic color with light, dark, and custom fallback palettes.
| Module | Purpose |
| --- | --- |
| `app` | Phone UI, permissions, paired recording, and received watch files |
| `wear` | Watch UI, live charts, and standalone recording |
| `recording-core` | Pure Kotlin recording state machine and cleanup rules |
| `sensorservices` | Android sensor, GPS, foreground-service, and file adapters |
| `WearOsLib` | Versioned commands and Channel file transport |
| `core` / `core-common` | Preferences, storage, diagnostics, results, and errors |

## Architecture
Hilt wires the Android implementations behind testable interfaces. Each device owns its recording clock and local files.

The UI modules use unidirectional MVI:
## Build it

`Composable → Intent → ViewModel → use case → repository/service → State + Effect`

UI launchers execute one-shot effects, while decisions and state transitions remain in workflow-owned ViewModels, reducers, and focused use cases. The phone shell owns navigation only. Hilt provides production dependencies and interfaces keep platform boundaries replaceable in tests.

Modules:

- `app`: phone Compose UI, workflow-owned MVI, paired-recording policy, permissions, and received watch files.
- `wear`: Wear Compose Material 3 UI, MVI, live charts, recording, and phone launch flow.
- `core-common`: platform-neutral `AppResult`, stable application errors, and diagnostics contracts.
- `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 v5 JSON commands, and Channel file transport. App policy stays in `app` and `wear`.

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

- Android Gradle Plugin 9.3.2 and Gradle 9.7
- Android compile/target SDK 37 (Android 17)
- Java 17 and Kotlin 2.4.10
- Jetpack Compose Material 3 and Wear Compose Material 3
- Hilt 2.60.1
- DataStore Preferences 1.2.1
- Detekt 2 with formatting rules and no baselines

Every Kotlin function is checked at a maximum of 40 lines. Compose functions therefore also stay below the requested 60-line ceiling.

## Build and quality checks

Install JDK 17 and Android SDK 37, then run:
You need JDK 17 and Android SDK 37. A Wear OS device or emulator is only needed to run the watch app and paired tests.

```shell
./gradlew :app:assembleDebug :wear:assembleDebug
./gradlew testDebugUnitTest detekt
./gradlew :app:lintDebug :wear:lintDebug
./gradlew :app:validateDebugScreenshotTest :wear:validateDebugScreenshotTest
```

Instrumentation test sources can be compiled without a device:

```shell
./gradlew :app:compileDebugAndroidTestKotlin :wear:compileDebugAndroidTestKotlin
```

Tests use Given/When/Then naming, reusable state/repository fixtures, coroutine test contexts, and Compose robots for end-to-end UI interactions.

## Emulator integration tests

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 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 \
"-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.WearSensorRecordingEmulatorTest"
```

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 recording archive.

The phone-driven paired recording test runs instrumentation only on the phone. It discovers a real Wear sensor, starts and stops a watch recording over the production message protocol, asks Wear OS to sync measurements, and verifies the transferred metadata and sensor samples on the phone:

```shell
PHONE_SERIAL=emulator-5554 WEAR_SERIAL=emulator-5556 \
tools/emulator/run_phone_paired_recording_test.sh
```

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

## Dependencies

The former Flipper, AppIntro, Material Dialogs, NumberPicker, Android About Page, LicensesDialog, Toasty, GraphView, and custom countdown modules have been removed. Their replacements are native APIs or small project-owned Compose components.

[Vico](https://github.com/patrykandpatrick/vico) is retained as the sole feature-level third-party UI library because it provides a maintained, Compose-native chart model and renderer suitable for the live Wear OS plot. AndroidX, Google Play services for Wear/location, Kotlin coroutines, Hilt, and Detekt remain infrastructure dependencies.

## Privacy

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
No Firebase project, Maps key, secrets file, or external-storage permission is required.

SensorBox is licensed under the Apache License 2.0. See [LICENSE](LICENSE).
SensorBox is licensed under the [Apache License 2.0](LICENSE).
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.tomasrepcik.sensorbox.wearoslib.connectivity
package com.tomasrepcik.sensorbox.wearoslib.connection

import android.content.Context
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Node
import com.google.android.gms.wearable.Wearable
import com.tomasrepcik.sensorbox.core.error.AppError
import com.tomasrepcik.sensorbox.core.error.AppErrorCode
import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.error.suspendAppResult
import com.tomasrepcik.sensorbox.core.error.suspendFlatMap
import com.tomasrepcik.sensorbox.core.failure.AppError
import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
import com.tomasrepcik.sensorbox.core.failure.AppResult
import com.tomasrepcik.sensorbox.core.failure.suspendAppResult
import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.tomasrepcik.sensorbox.wearoslib.connectivity
package com.tomasrepcik.sensorbox.wearoslib.connection

data class WearNode(val id: String, val displayName: String, val isNearby: Boolean)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.tomasrepcik.sensorbox.wearoslib.connectivity
package com.tomasrepcik.sensorbox.wearoslib.connection

import dagger.Binds
import dagger.Module
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.tomasrepcik.sensorbox.wearoslib.connectivity
package com.tomasrepcik.sensorbox.wearoslib.connection

import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.failure.AppResult
import kotlinx.coroutines.flow.Flow

interface WearConnectionRepository {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.tomasrepcik.sensorbox.wearoslib.connectivity
package com.tomasrepcik.sensorbox.wearoslib.connection

import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.failure.AppResult
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.tomasrepcik.sensorbox.wearoslib.connectivity
package com.tomasrepcik.sensorbox.wearoslib.connection

internal object WearNodeSelector {
fun select(nodes: Collection<WearNode>): WearNode? =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.tomasrepcik.sensorbox.wearoslib
package com.tomasrepcik.sensorbox.wearoslib.connection

object WearOsConstants {
const val PHONE_APP_CAPABILITY = "phone_app"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.tomasrepcik.sensorbox.wearoslib.protocol
package com.tomasrepcik.sensorbox.wearoslib.pairedrecording

import com.tomasrepcik.sensorbox.core.error.AppError
import com.tomasrepcik.sensorbox.core.error.AppErrorCode
import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.failure.AppError
import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
import com.tomasrepcik.sensorbox.core.failure.AppResult
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.tomasrepcik.sensorbox.wearoslib.protocol
package com.tomasrepcik.sensorbox.wearoslib.pairedrecording

import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.error.suspendFlatMap
import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase
import com.tomasrepcik.sensorbox.core.failure.AppResult
import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap
import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase
import javax.inject.Inject

class SendWearCommandUseCase @Inject constructor(private val sendMessage: SendWearMessageUseCase) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.tomasrepcik.sensorbox.wearoslib.protocol
package com.tomasrepcik.sensorbox.wearoslib.pairedrecording

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.tomasrepcik.sensorbox.wearoslib.protocol
package com.tomasrepcik.sensorbox.wearoslib.pairedrecording

import com.tomasrepcik.sensorbox.core.error.AppError
import com.tomasrepcik.sensorbox.core.error.AppErrorCode
import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.error.appResult
import com.tomasrepcik.sensorbox.core.error.flatMap
import com.tomasrepcik.sensorbox.core.failure.AppError
import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
import com.tomasrepcik.sensorbox.core.failure.AppResult
import com.tomasrepcik.sensorbox.core.failure.appResult
import com.tomasrepcik.sensorbox.core.failure.flatMap
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.tomasrepcik.sensorbox.wearoslib.files
package com.tomasrepcik.sensorbox.wearoslib.sync

import android.content.Context
import com.google.android.gms.wearable.ChannelClient
import com.google.android.gms.wearable.Wearable
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.withAppError
import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
import com.tomasrepcik.sensorbox.core.failure.AppResult
import com.tomasrepcik.sensorbox.core.failure.combineAppResults
import com.tomasrepcik.sensorbox.core.failure.suspendAppResult
import com.tomasrepcik.sensorbox.core.failure.withAppError
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.tasks.await
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.tomasrepcik.sensorbox.wearoslib.files
package com.tomasrepcik.sensorbox.wearoslib.sync

import kotlinx.serialization.Serializable

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.tomasrepcik.sensorbox.wearoslib.files
package com.tomasrepcik.sensorbox.wearoslib.sync

import com.tomasrepcik.sensorbox.core.error.AppError
import com.tomasrepcik.sensorbox.core.error.AppErrorCode
import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.error.appResult
import com.tomasrepcik.sensorbox.core.error.flatMap
import com.tomasrepcik.sensorbox.core.failure.AppError
import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
import com.tomasrepcik.sensorbox.core.failure.AppResult
import com.tomasrepcik.sensorbox.core.failure.appResult
import com.tomasrepcik.sensorbox.core.failure.flatMap
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.tomasrepcik.sensorbox.wearoslib.files
package com.tomasrepcik.sensorbox.wearoslib.sync

import com.google.android.gms.wearable.ChannelClient
import com.tomasrepcik.sensorbox.core.error.AppResult
import com.tomasrepcik.sensorbox.core.failure.AppResult
import java.io.InputStream

interface WearFileTransferClient {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.tomasrepcik.sensorbox.wearoslib.files
package com.tomasrepcik.sensorbox.wearoslib.sync

import dagger.Binds
import dagger.Module
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.tomasrepcik.sensorbox.wearoslib.connectivity
package com.tomasrepcik.sensorbox.wearoslib.connection

import org.junit.Assert.assertEquals
import org.junit.Test
Expand Down
Loading