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).
11 changes: 10 additions & 1 deletion WearOsLib/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
<manifest />
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application>
<service
android:name="com.tomasrepcik.sensorbox.wearoslib.sync.WearTransferWorkService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
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
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.flow.retryWhen
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds

@Singleton
class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext context: Context) :
Expand All @@ -35,19 +35,13 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext
}
capabilityClient.addListener(capabilityListener, capability).await()
trySend(loadConnection(capability))
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)
}.retryWhen { _, _ ->
emit(WearConnection.Disconnected)
delay(CONNECTION_RETRY_DELAY_MILLIS.milliseconds)
true
}.distinctUntilChanged()

override suspend fun findNode(capability: String): WearNode? {
Expand Down Expand Up @@ -75,18 +69,13 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext
?.let(WearConnection::Connected)
?: WearConnection.Disconnected

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

private fun Node.toWearNode() = WearNode(
id = id,
displayName = displayName,
isNearby = isNearby,
)

private companion object {
const val NODE_POLL_INTERVAL_MILLIS = 2_000L
const val CONNECTION_RETRY_DELAY_MILLIS = 2_000L
}
}
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
Loading