Skip to content
Merged
7 changes: 4 additions & 3 deletions HARDWARE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Hardware verification guide

Everything in this repo is verified by 1,173 JVM tests that run without
Everything in this repo is verified by 1,275 JVM tests that run without
hardware. Three things remain that can only be proven against a real board
and phone. This guide is the exact procedure.

Expand Down Expand Up @@ -80,8 +80,9 @@ Then on the phone:
- **Logging**: start a log, wait ~30 s, stop, download — progress bar
completes, session appears in history, CSV export shares a file.
- **Settings**: rename the device; rescan to see the new name.
4. No board handy? Toggle **demo mode** on the scan screen — the entire app
runs against a synthesized board.
4. No board handy? The app itself needs hardware, but the JVM suite drives
the same device stack against `DemoBleTransport` (a test-only emulator):
`./gradlew :app:testDebugUnitTest`.

## Troubleshooting

Expand Down
2,071 changes: 1,776 additions & 295 deletions README.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ plugins {

// Jetpack Compose MetaWear app: scanning, live streaming with ring-buffer
// decimation, on-device logging + download, session history with CSV export,
// LED/haptic controls, device settings, firmware updates, and a hardware-free
// demo mode.
// LED/haptic controls, device settings, and firmware updates.
android {
namespace = "com.mbientlab.metawear.app"
compileSdk = 36
Expand Down Expand Up @@ -52,13 +51,15 @@ dependencies {
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.core.ktx)

// JVM unit tests: RingBuffer, Channel decimation, CSV export, filenames,
// bandwidth advisor, demo-transport round trips, group-capture walks.
// bandwidth advisor, group-capture walks, and end-to-end walks through the
// real MetaWearDevice against DemoBleTransport (a test-only emulator).
testImplementation(libs.junit.jupiter)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,23 @@
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<!-- Nordic DFU service runs as a foreground service during flashing. -->
<!-- Firmware updates fetch the release catalog and firmware archives from
MbientLab's release server. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- BLE is preferred but not required: demo mode works without radios. -->
<!-- BLE is required to talk to boards, but declared optional so the app
still installs on radio-less devices (session history stays usable). -->
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />

<application
android:name=".MetaWearApplication"
android:label="MetaWear"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@android:style/Theme.Material.NoActionBar"
android:enableOnBackInvokedCallback="true"
tools:targetApi="tiramisu">
Expand Down
39 changes: 5 additions & 34 deletions app/src/main/kotlin/com/mbientlab/metawear/app/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import com.mbientlab.metawear.MetaWearDevice
import com.mbientlab.metawear.MetaWearScanner
import com.mbientlab.metawear.app.data.LogSessionRegistry
import com.mbientlab.metawear.app.data.RememberedDeviceStore
import com.mbientlab.metawear.app.demo.DemoBleTransport
import com.mbientlab.metawear.app.vm.GroupCaptureCoordinator
import com.mbientlab.metawear.core.AndroidMetaWear
import com.mbientlab.metawear.persistence.PersistenceDatabase
Expand All @@ -25,7 +24,7 @@ class AppContainer(context: Context) {

private val appContext = context.applicationContext

/** Process-lifetime host for SDK routers, scan collection, demo emitters. */
/** Process-lifetime host for SDK routers and scan collection. */
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

/** The app-wide scanner (one per process, per SDK guidance). */
Expand All @@ -52,51 +51,23 @@ class AppContainer(context: Context) {
/** Identifier of the device the detail screens operate on. */
val activeDeviceId = MutableStateFlow<String?>(null)

/** Demo-mode toggle: surfaces the simulated device in the scan list. */
val demoModeEnabled = MutableStateFlow(false)

/**
* Simulated MetaMotion S fleet (see [DemoBleTransport]) — three boards
* with distinct identities so multi-board flows (group logging, per-board
* attribution) are exercisable without hardware. Lazily created, reused
* across connect/disconnect cycles, never persisted as remembered.
*/
val demoFleet: List<MetaWearDevice> by lazy {
(0 until 3).map { index ->
val identity = DemoBleTransport.Identity.board(index)
MetaWearDevice(identity.identifier, DemoBleTransport(appScope, identity), appScope)
}
}

/** The legacy single demo board (fleet index 0). */
val demoDevice: MetaWearDevice get() = demoFleet[0]

/** Display name for demo fleet board [index] ("Simulated MetaWear", "… 2", …). */
fun demoName(index: Int): String =
if (index == 0) DemoBleTransport.DEVICE_NAME else "${DemoBleTransport.DEVICE_NAME} ${index + 1}"

/** Fleet-wide group logging coordinator (survives navigation). */
val groupCapture: GroupCaptureCoordinator by lazy {
GroupCaptureCoordinator(persistence, logSessions)
}

/** Resolve an identifier to a device (demo or scanner-vended). */
fun device(identifier: String): MetaWearDevice =
demoFleet.firstOrNull { it.identifier == identifier }
?: scanner.deviceForKnownIdentifier(identifier)
/** Resolve an identifier to its scanner-vended device. */
fun device(identifier: String): MetaWearDevice = scanner.deviceForKnownIdentifier(identifier)

/**
* Best display name for a board right now: live advertised name, then the
* remembered bookmark, then nothing. Used to stamp sessions at capture
* time — names are unrecoverable later (boards go off air; the advertised
* cache is per-host).
*/
fun displayNameFor(identifier: String): String? {
val demoIndex = demoFleet.indexOfFirst { it.identifier == identifier }
if (demoIndex >= 0) return demoName(demoIndex)
return scanner.advertisedNames.value[identifier]?.ifEmpty { null }
fun displayNameFor(identifier: String): String? =
scanner.advertisedNames.value[identifier]?.ifEmpty { null }
?: remembered.devices.value.firstOrNull { it.mac == identifier }?.name
}

/** The device the detail screens operate on. Null before any selection. */
fun activeDevice(): MetaWearDevice? = activeDeviceId.value?.let { device(it) }
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/kotlin/com/mbientlab/metawear/app/export/CsvShare.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.mbientlab.metawear.app.export

import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File

Expand All @@ -25,4 +26,28 @@ object CsvShare {
}
context.startActivity(Intent.createChooser(send, "Export $filename"))
}

/**
* Share several CSVs in one go (`ACTION_SEND_MULTIPLE`) — one chooser for
* a whole live-stream buffer instead of one per channel. A single file
* falls back to [share].
*/
fun shareAll(context: Context, files: List<Pair<String, String>>) {
if (files.isEmpty()) return
if (files.size == 1) return share(context, files[0].first, files[0].second)
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
val uris = ArrayList<Uri>()
for ((filename, csv) in files) {
val file = File(dir, filename)
file.writeText(csv)
uris += FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
val send = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "text/csv"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
putExtra(Intent.EXTRA_SUBJECT, "MetaWear export (${files.size} files)")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, "Export ${files.size} CSV files"))
}
}
37 changes: 28 additions & 9 deletions app/src/main/kotlin/com/mbientlab/metawear/app/ui/AppNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,28 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.mbientlab.metawear.app.ui.controls.ControlsScreen
import com.mbientlab.metawear.app.ui.device.DeviceDetailScreen
import com.mbientlab.metawear.app.ui.device.DeviceInfoScreen
import com.mbientlab.metawear.app.ui.firmware.FirmwareScreen
import com.mbientlab.metawear.app.ui.logging.GroupLoggingScreen
import com.mbientlab.metawear.app.ui.logging.LoggingScreen
import com.mbientlab.metawear.app.ui.scan.ScanScreen
import com.mbientlab.metawear.app.ui.sessions.SessionDetailScreen
import com.mbientlab.metawear.app.ui.sessions.SessionsScreen
import com.mbientlab.metawear.app.ui.settings.SettingsScreen
import com.mbientlab.metawear.app.ui.stream.LiveStreamScreen

/** Navigation graph: scan → device hub → feature screens. */
/** Navigation graph: scan → device hub → feature screens (+ session history/detail). */
@Composable
fun AppNavHost() {
val navController = rememberNavController()
val back: () -> Unit = { navController.popBackStack() }

NavHost(
navController = navController,
Expand All @@ -35,23 +39,38 @@ fun AppNavHost() {
ScanScreen(
onDeviceSelected = { navController.navigate("device") },
onGroupLogging = { navController.navigate("group") },
onSessionHistory = { navController.navigate("sessions") },
)
}
composable("group") { GroupLoggingScreen() }
composable("group") {
GroupLoggingScreen(onBack = back, onSessionHistory = { navController.navigate("sessions") })
}
composable("device") {
DeviceDetailScreen(
onNavigate = { route -> navController.navigate(route) },
onDisconnected = { navController.popBackStack("scan", inclusive = false) },
onBack = back,
)
}
composable("info") { DeviceInfoScreen() }
composable("stream") { LiveStreamScreen() }
composable("logging") { LoggingScreen() }
composable("sessions") { SessionsScreen() }
composable("controls") { ControlsScreen() }
composable("info") { DeviceInfoScreen(onBack = back) }
composable("stream") { LiveStreamScreen(onBack = back) }
composable("logging") { LoggingScreen(onBack = back) }
composable("sessions") {
SessionsScreen(onBack = back, onOpenSession = { id -> navController.navigate("session/$id") })
}
composable(
route = "session/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
SessionDetailScreen(sessionId = entry.arguments?.getString("id").orEmpty(), onBack = back)
}
composable("controls") { ControlsScreen(onBack = back) }
composable("settings") {
SettingsScreen(onFactoryReset = { navController.popBackStack("device", inclusive = false) })
SettingsScreen(
onFactoryReset = { navController.popBackStack("device", inclusive = false) },
onBack = back,
)
}
composable("firmware") { FirmwareScreen() }
composable("firmware") { FirmwareScreen(onBack = back) }
}
}
Loading
Loading