commit be47be3b820c5a4c955dda177244365694342943 Author: tom.hempel Date: Thu Sep 18 11:54:08 2025 +0200 initial upload diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7415cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..2adf9e0 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Lab Recorder \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..7d63619 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef7ecc2 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# LabRecorder + +A simple Android app to record heart rate (HR) and RR-interval data simultaneously from two Polar BLE heart rate sensors. + +*** + +## How to Use + +1. **Install the App**: You can install the app directly using the `app-release.apk` file in the repository. Grant the necessary Bluetooth permissions when prompted. +2. **Enter Group ID**: Type a unique name for your recording session in the **Group ID** field. +3. **Scan & Connect**: The app automatically scans for Polar devices. Once found, they will be auto-assigned. Click **Connect** and wait for the status to change to "Connected". +4. **Record**: Click **Start Recording**. You can log events using the **Start/Stop Interval** and **Mark Timestamp** buttons. +5. **Stop**: Click **Stop Recording** to save the session. + +*** + +## Data Output + +Recorded data is saved as `.csv` files on your device. You can find them in the following directory: + +`Documents/LabRecorder//` diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..70f206b --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.tomhempel.labrecorder" + compileSdk = 36 + + defaultConfig { + applicationId = "com.tomhempel.labrecorder" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + viewBinding = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.material) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui.ktx) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.compose.material:material-icons-extended-android:1.6.8") + implementation(platform("androidx.compose:compose-bom:2024.06.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("com.patrykandpatrick.vico:compose-m3:1.14.0") + implementation("com.patrykandpatrick.vico:core:1.14.0") + implementation("com.github.PhilJay:MPAndroidChart:v3.1.0") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..e303143 Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..acba6cd Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/lab-recorder.apk b/app/release/lab-recorder.apk new file mode 100644 index 0000000..b96c73b Binary files /dev/null and b/app/release/lab-recorder.apk differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..4fb3077 --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.tomhempel.labrecorder", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 24 +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/tomhempel/labrecorder/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/tomhempel/labrecorder/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..cd3c446 --- /dev/null +++ b/app/src/androidTest/java/com/tomhempel/labrecorder/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.tomhempel.labrecorder + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.tomhempel.labrecorder", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c304e7c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/labrecorder/DataInspectionActivity.kt b/app/src/main/java/com/tomhempel/labrecorder/DataInspectionActivity.kt new file mode 100644 index 0000000..52686e9 --- /dev/null +++ b/app/src/main/java/com/tomhempel/labrecorder/DataInspectionActivity.kt @@ -0,0 +1,221 @@ +package com.tomhempel.labrecorder + +import android.content.Intent +import android.os.Bundle +import android.os.Environment +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tomhempel.labrecorder.ui.theme.LabRecorderTheme +import java.io.File + +class DataInspectionActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + LabRecorderTheme { + DataInspectionScreen( + onNavigateBack = { finish() }, + onRecordingSelected = { recordingPath, isSingleParticipant -> + val intent = Intent(this, TimeNormalizedPlotActivity::class.java).apply { + putExtra("RECORDING_PATH", recordingPath) + } + startActivity(intent) + } + ) + } + } + } +} + +enum class RecordingType { + SINGLE, GROUP +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DataInspectionScreen( + onNavigateBack: () -> Unit, + onRecordingSelected: (String, Boolean) -> Unit +) { + val baseDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "LabRecorder") + var recordings by remember { mutableStateOf(listRecordings(baseDir)) } + var selectedTab by remember { mutableStateOf(RecordingType.SINGLE) } + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text("Recorded Data") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + TabRow( + selectedTabIndex = if (selectedTab == RecordingType.SINGLE) 0 else 1 + ) { + Tab( + selected = selectedTab == RecordingType.SINGLE, + onClick = { selectedTab = RecordingType.SINGLE }, + text = { Text("Single Recordings") } + ) + Tab( + selected = selectedTab == RecordingType.GROUP, + onClick = { selectedTab = RecordingType.GROUP }, + text = { Text("Group Recordings") } + ) + } + } + } + ) { padding -> + val filteredRecordings = recordings.filter { + when (selectedTab) { + RecordingType.SINGLE -> it.isSingleParticipant + RecordingType.GROUP -> !it.isSingleParticipant + } + } + + if (filteredRecordings.isEmpty()) { + // Show empty state + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = when (selectedTab) { + RecordingType.SINGLE -> "No single participant recordings found" + RecordingType.GROUP -> "No group recordings found" + }, + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 24.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(filteredRecordings) { recording -> + RecordingItem( + recording = recording, + onClick = { + val recordingPath = if (recording.isSingleParticipant) { + File(baseDir, "SingleRecordings/${recording.name}").absolutePath + } else { + File(baseDir, recording.name).absolutePath + } + onRecordingSelected(recordingPath, recording.isSingleParticipant) + } + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecordingItem( + recording: RecordingInfo, + onClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = recording.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (recording.isSingleParticipant) + "Participant ID: ${recording.name}" + else + "Group ID: ${recording.name}", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Files: ${recording.fileCount}", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +data class RecordingInfo( + val name: String, + val isSingleParticipant: Boolean, + val fileCount: Int +) + +private fun listRecordings(baseDir: File): List { + if (!baseDir.exists()) return emptyList() + + val recordings = mutableListOf() + + // Check SingleRecordings directory + val singleRecordingsDir = File(baseDir, "SingleRecordings") + if (singleRecordingsDir.exists()) { + singleRecordingsDir.listFiles()?.forEach { participantDir -> + if (participantDir.isDirectory) { + recordings.add( + RecordingInfo( + name = participantDir.name, + isSingleParticipant = true, + fileCount = participantDir.listFiles()?.size ?: 0 + ) + ) + } + } + } + + // Check group recordings (directories directly in baseDir except SingleRecordings) + baseDir.listFiles()?.forEach { dir -> + if (dir.isDirectory && dir.name != "SingleRecordings") { + recordings.add( + RecordingInfo( + name = dir.name, + isSingleParticipant = false, + fileCount = dir.listFiles()?.size ?: 0 + ) + ) + } + } + + return recordings.sortedBy { it.name } +} \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/labrecorder/MainActivity.kt b/app/src/main/java/com/tomhempel/labrecorder/MainActivity.kt new file mode 100644 index 0000000..74ea5a5 --- /dev/null +++ b/app/src/main/java/com/tomhempel/labrecorder/MainActivity.kt @@ -0,0 +1,822 @@ +package com.tomhempel.labrecorder + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Application +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.activity.enableEdgeToEdge +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.tomhempel.labrecorder.ui.theme.LabRecorderTheme +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID +import android.content.Intent +import androidx.appcompat.app.AlertDialog + +private const val TAG = "LabRecorder" + +// Standard Bluetooth Service and Characteristic UUIDs +private val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") +private val HR_MEASUREMENT_CHAR_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") +private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + +data class BleDevice(val name: String, val address: String) + +@SuppressLint("MissingPermission") +class MainViewModel(application: Application) : AndroidViewModel(application) { + + // --- Bluetooth & System Services --- + private val bluetoothManager: BluetoothManager by lazy { + application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + } + private val bluetoothAdapter by lazy { bluetoothManager.adapter } + private val bluetoothLeScanner by lazy { bluetoothAdapter.bluetoothLeScanner } + + // --- UI State Management --- + val isScanning = mutableStateOf(false) + val discoveredDevices = mutableStateListOf() + val groupId = mutableStateOf("") + val participantId = mutableStateOf("") // New state for single participant ID + val isRecording = mutableStateOf(false) + val isIntervalRunning = mutableStateOf(false) + val connectionState1 = mutableStateOf("Disconnected") + val connectionState2 = mutableStateOf("Disconnected") + val hrValue1 = mutableStateOf(0) + val hrValue2 = mutableStateOf(0) + val logMessages = mutableStateListOf() + val selectedDevice1 = mutableStateOf(null) + val selectedDevice2 = mutableStateOf(null) + val numberOfParticipants = mutableStateOf(0) // New state for number of participants + + + // --- Connection & File Management --- + private var gatt1: BluetoothGatt? = null + private var gatt2: BluetoothGatt? = null + private var hrWriter1: BufferedWriter? = null + private var rrWriter1: BufferedWriter? = null + private var hrWriter2: BufferedWriter? = null + private var rrWriter2: BufferedWriter? = null + private var timestampWriter: BufferedWriter? = null + private var scanTimeoutJob: Job? = null + + init { + addLog("App initialized. Welcome!") + } + + // --- Logging Helper --- + private fun addLog(message: String) { + val timeStamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) + val logEntry = "$timeStamp: $message" + logMessages.add(0, logEntry) + if (logMessages.size > 100) { + logMessages.removeLast() + } + Log.d(TAG, message) + } + + // --- GATT Callback for Connection & Data Events --- + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + val deviceAddress = gatt.device.address + val (stateToUpdate, hrToUpdate, participant) = if (deviceAddress == gatt1?.device?.address) { + Triple(connectionState1, hrValue1, "P1") + } else { + Triple(connectionState2, hrValue2, "P2") + } + + if (status == BluetoothGatt.GATT_SUCCESS) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + stateToUpdate.value = "Connected" + addLog("[$participant] Connected to $deviceAddress. Discovering services...") + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + stateToUpdate.value = "Disconnected" + hrToUpdate.value = 0 + addLog("[$participant] Disconnected from $deviceAddress.") + gatt.close() + } + } else { + stateToUpdate.value = "Error" + hrToUpdate.value = 0 + addLog("[$participant] ERROR: GATT Error for $deviceAddress. Status: $status") + gatt.close() + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + val participant = if (gatt.device.address == gatt1?.device?.address) "P1" else "P2" + if (status == BluetoothGatt.GATT_SUCCESS) { + addLog("[$participant] Services discovered. Enabling HR notifications.") + enableHeartRateNotifications(gatt) + } else { + addLog("[$participant] ERROR: Service discovery failed.") + } + } + + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { + if (characteristic.uuid == HR_MEASUREMENT_CHAR_UUID) { + parseAndRecordData(gatt.device.address, value) + } + } + } + + // --- Scanning Logic --- + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val deviceName = result.device.name + if (deviceName?.startsWith("Polar", ignoreCase = true) == true) { + val device = BleDevice(deviceName, result.device.address) + if (discoveredDevices.none { it.address == device.address }) { + addLog("Found device: ${device.name}") + discoveredDevices.add(device) + if (discoveredDevices.size >= numberOfParticipants.value) { + addLog("Found ${numberOfParticipants.value} Polar device(s). Stopping scan.") + stopBleScan() + } + } + } + } + + override fun onScanFailed(errorCode: Int) { + super.onScanFailed(errorCode) + addLog("ERROR: BLE Scan Failed with code: $errorCode") + isScanning.value = false + } + } + + // --- Data Processing --- + private fun parseAndRecordData(deviceAddress: String, data: ByteArray) { + val isDevice1 = deviceAddress == gatt1?.device?.address + val hrState = if (isDevice1) hrValue1 else hrValue2 + val currentHrWriter = if (isDevice1) hrWriter1 else hrWriter2 + val currentRrWriter = if (isDevice1) rrWriter1 else rrWriter2 + + val flags = data[0].toInt() + val is16BitFormat = (flags and 0x01) != 0 + val hrValue = if (is16BitFormat) { + (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8) + } else { + data[1].toInt() and 0xFF + } + hrState.value = hrValue + + currentHrWriter?.let { writer -> + try { + val timestamp = System.currentTimeMillis() + writer.write("$timestamp,$hrValue\n") + } catch (e: IOException) { + Log.e(TAG, "Error writing HR data for $deviceAddress", e) + } + } + + val rrIntervalsPresent = (flags and 0x10) != 0 + if (rrIntervalsPresent) { + var offset = if (is16BitFormat) 3 else 2 + while (offset < data.size) { + val rrValue = ((data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)) + currentRrWriter?.let { writer -> + try { + val timestamp = System.currentTimeMillis() + writer.write("$timestamp,$rrValue\n") + } catch (e: IOException) { + Log.e(TAG, "Error writing RR data for $deviceAddress", e) + } + } + offset += 2 + } + } + } + + // --- BLE Actions --- + private fun enableHeartRateNotifications(gatt: BluetoothGatt) { + val hrCharacteristic = gatt.getService(HR_SERVICE_UUID)?.getCharacteristic(HR_MEASUREMENT_CHAR_UUID) + if (hrCharacteristic == null) { + addLog("ERROR: Heart Rate characteristic not found on ${gatt.device.address}") + return + } + gatt.setCharacteristicNotification(hrCharacteristic, true) + val descriptor = hrCharacteristic.getDescriptor(CCCD_UUID) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } else { + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(descriptor) + } + addLog("Enabling notifications for ${gatt.device.address}") + } + + fun startBleScan() { + addLog("Starting automatic BLE scan (2s or 2 devices)...") + disconnectAll() + discoveredDevices.clear() + selectedDevice1.value = null + selectedDevice2.value = null + isScanning.value = true + val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + bluetoothLeScanner?.startScan(null, settings, scanCallback) + + scanTimeoutJob?.cancel() + scanTimeoutJob = viewModelScope.launch { + delay(2000) + if (isScanning.value) { + addLog("Scan timeout (2s) reached.") + stopBleScan() + } + } + } + + fun stopBleScan() { + if (!isScanning.value) return + addLog("Stopping BLE scan.") + isScanning.value = false + scanTimeoutJob?.cancel() + scanTimeoutJob = null + bluetoothLeScanner?.stopScan(scanCallback) + + // Auto-select devices after scan stops and connect + if (discoveredDevices.isNotEmpty()) { + selectedDevice1.value = discoveredDevices.getOrNull(0) + selectedDevice2.value = discoveredDevices.getOrNull(1) + connectToDevices() + } + } + + fun connectToDevices() { + val address1 = selectedDevice1.value?.address ?: "" + val address2 = if (numberOfParticipants.value == 2) selectedDevice2.value?.address else "" + disconnectAll() + if (address1.isNotEmpty()) { + val device = bluetoothAdapter?.getRemoteDevice(address1) + addLog("Attempting to connect to P1: $address1") + gatt1 = device?.connectGatt(getApplication(), false, gattCallback) + } + if (address2?.isNotEmpty() == true && address1 != address2) { + val device = bluetoothAdapter?.getRemoteDevice(address2) + addLog("Attempting to connect to P2: $address2") + gatt2 = device?.connectGatt(getApplication(), false, gattCallback) + } + } + + fun disconnectAll() { + gatt1?.disconnect() + gatt2?.disconnect() + } + + // --- File Recording Logic --- + fun startRecording() { + if (numberOfParticipants.value == 2 && groupId.value.isBlank()) { + addLog("ERROR: Group ID cannot be empty.") + Toast.makeText(getApplication(), "Group ID cannot be empty.", Toast.LENGTH_SHORT).show() + return + } else if (numberOfParticipants.value == 1 && participantId.value.isBlank()) { + addLog("ERROR: Participant ID cannot be empty.") + Toast.makeText(getApplication(), "Participant ID cannot be empty.", Toast.LENGTH_SHORT).show() + return + } + + val baseDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "LabRecorder") + val recordingDir = if (numberOfParticipants.value == 2) { + addLog("Starting recording for group: ${groupId.value}") + File(baseDir, groupId.value) + } else { + addLog("Starting recording for participant: ${participantId.value}") + val singleRecordingsDir = File(baseDir, "SingleRecordings") + File(singleRecordingsDir, participantId.value) + } + + if (!recordingDir.exists() && !recordingDir.mkdirs()) { + addLog("ERROR: Failed to create recording directory.") + return + } + + if (gatt1?.device?.address != null) { + val deviceDir = if (numberOfParticipants.value == 2) { + File(recordingDir, "Participant_1") + } else { + recordingDir // For single participant, use the participant directory directly + } + deviceDir.mkdirs() + try { + hrWriter1 = BufferedWriter(FileWriter(File(deviceDir, "hr.csv"))).apply { write("timestamp,hr\n") } + rrWriter1 = BufferedWriter(FileWriter(File(deviceDir, "rr.csv"))).apply { write("timestamp,rr_ms\n") } + } catch (e: IOException) { addLog("ERROR: Failed creating writers for P1.") } + } + + if (numberOfParticipants.value == 2 && gatt2?.device?.address != null) { + val deviceDir = File(recordingDir, "Participant_2") + deviceDir.mkdirs() + try { + hrWriter2 = BufferedWriter(FileWriter(File(deviceDir, "hr.csv"))).apply { write("timestamp,hr\n") } + rrWriter2 = BufferedWriter(FileWriter(File(deviceDir, "rr.csv"))).apply { write("timestamp,rr_ms\n") } + } catch (e: IOException) { addLog("ERROR: Failed creating writers for P2.") } + } + + try { + timestampWriter = BufferedWriter(FileWriter(File(recordingDir, "timestamps.csv"))).apply { write("timestamp,event_type\n") } + } catch(e: IOException) { addLog("ERROR: Failed creating timestamp writer.") } + + isRecording.value = true + Toast.makeText(getApplication(), "Recording started.", Toast.LENGTH_SHORT).show() + } + + fun stopRecording() { + addLog("Stopping recording.") + isRecording.value = false + isIntervalRunning.value = false + try { + hrWriter1?.close() + rrWriter1?.close() + hrWriter2?.close() + rrWriter2?.close() + timestampWriter?.close() + } catch (e: IOException) { addLog("ERROR: Failed closing file writers.") } + + hrWriter1 = null; rrWriter1 = null; hrWriter2 = null; rrWriter2 = null; timestampWriter = null + Toast.makeText(getApplication(), "Recording stopped.", Toast.LENGTH_SHORT).show() + } + + fun markTimestamp(eventType: String) { + if (!isRecording.value) { + val msg = "Must be recording to mark a timestamp." + addLog("INFO: $msg") + Toast.makeText(getApplication(), msg, Toast.LENGTH_SHORT).show() + return + } + timestampWriter?.let { writer -> + try { + val timestamp = System.currentTimeMillis() + writer.write("$timestamp,$eventType\n") + writer.flush() + val msg = "'$eventType' marked." + addLog(msg) + Toast.makeText(getApplication(), msg, Toast.LENGTH_SHORT).show() + } catch (e: IOException) { + addLog("ERROR: Could not write timestamp.") + } + } + } + + override fun onCleared() { + super.onCleared() + if (isRecording.value) { + stopRecording() + } + disconnectAll() + addLog("ViewModel cleared. Resources released.") + } +} + +class MainActivity : ComponentActivity() { + + private val viewModel: MainViewModel by viewModels() + private var showExitDialog by mutableStateOf(false) + + private val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.values.all { it }) { + // Permissions granted, start the scan + viewModel.startBleScan() + } else { + Toast.makeText(this, "Bluetooth permissions are required for this app to function.", Toast.LENGTH_LONG).show() + } + } + + override fun onBackPressed() { + // If recording is in progress, show a warning dialog + if (viewModel.isRecording.value) { + showExitDialog = true + } else { + returnToSelection() + } + } + + private fun returnToSelection() { + // Stop any ongoing operations + viewModel.stopBleScan() + viewModel.disconnectAll() + + // Start ParticipantSelectionActivity + val intent = Intent(this, ParticipantSelectionActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + startActivity(intent) + finish() + } + + private fun hasRequiredBluetoothPermissions(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + } else { + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + } + } + + private fun requestRelevantPermissions() { + val permissionsToRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + requestPermissionLauncher.launch(permissionsToRequest) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Get the number of participants from the intent + val numberOfParticipants = intent.getIntExtra("NUMBER_OF_PARTICIPANTS", 2) + viewModel.numberOfParticipants.value = numberOfParticipants + + // Lock orientation to vertical to prevent activity recreation on rotation + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + setContent { + LabRecorderTheme { + if (showExitDialog) { + AlertDialog( + onDismissRequest = { showExitDialog = false }, + title = { Text("Recording in Progress") }, + text = { Text("Stop recording and return to selection screen?") }, + confirmButton = { + TextButton(onClick = { + showExitDialog = false + viewModel.stopRecording() + returnToSelection() + }) { + Text("Yes") + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false }) { + Text("No") + } + } + ) + } + + Scaffold { innerPadding -> + MainScreen( + modifier = Modifier.padding(innerPadding), + viewModel = viewModel + ) + } + } + } + + // Check for permissions and start scanning if granted + if (hasRequiredBluetoothPermissions()) { + viewModel.startBleScan() + } else { + requestRelevantPermissions() + } + } +} + +@Composable +fun MainScreen( + modifier: Modifier = Modifier, + viewModel: MainViewModel +) { + // Read state from the ViewModel + val isScanning by viewModel.isScanning + val discoveredDevices = viewModel.discoveredDevices + val connectionStatus1 by viewModel.connectionState1 + val connectionStatus2 by viewModel.connectionState2 + val hrValue1 by viewModel.hrValue1 + val hrValue2 by viewModel.hrValue2 + val groupId by viewModel.groupId + val participantId by viewModel.participantId + val isRecording by viewModel.isRecording + val isIntervalRunning by viewModel.isIntervalRunning + val logMessages = viewModel.logMessages + val selectedDevice1 by viewModel.selectedDevice1 + val selectedDevice2 by viewModel.selectedDevice2 + val numberOfParticipants by viewModel.numberOfParticipants + + val dropdownOptions = when { + discoveredDevices.isNotEmpty() -> discoveredDevices + isScanning -> listOf(BleDevice("Scanning...", "")) + else -> listOf(BleDevice("No devices found", "")) + } + + var expanded1 by remember { mutableStateOf(false) } + var expanded2 by remember { mutableStateOf(false) } + + Column( + modifier = modifier.fillMaxSize().padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // --- Section 1: Setup --- + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (numberOfParticipants == 2) { + OutlinedTextField( + value = groupId, + onValueChange = { viewModel.groupId.value = it }, + label = { Text("Group ID *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isRecording + ) + } else if (numberOfParticipants == 1) { + OutlinedTextField( + value = participantId, + onValueChange = { viewModel.participantId.value = it }, + label = { Text("Participant ID *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isRecording + ) + } + Button( + onClick = { if (isScanning) viewModel.stopBleScan() else viewModel.startBleScan() }, + modifier = Modifier.fillMaxWidth(), + enabled = !isRecording + ) { + if (isScanning) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary) + Spacer(Modifier.width(8.dp)) + Text("Stop Scan") + } else { + Text("Scan for Polar Devices") + } + } + } + } + + // --- Section 2: Device Connection --- + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + DeviceSelectionDropdown( + label = "Participant 1", + options = dropdownOptions, + selectedOption = selectedDevice1, + onOptionSelected = { viewModel.selectedDevice1.value = it }, + expanded = expanded1, + onExpandedChange = { expanded1 = it }, + enabled = discoveredDevices.isNotEmpty() && !isRecording, + status = connectionStatus1, + hrValue = hrValue1 + ) + if (numberOfParticipants == 2) { + DeviceSelectionDropdown( + label = "Participant 2", + options = dropdownOptions, + selectedOption = selectedDevice2, + onOptionSelected = { viewModel.selectedDevice2.value = it }, + expanded = expanded2, + onExpandedChange = { expanded2 = it }, + enabled = discoveredDevices.isNotEmpty() && !isRecording, + status = connectionStatus2, + hrValue = hrValue2 + ) + } + Button( + onClick = { viewModel.connectToDevices() }, + enabled = (selectedDevice1 != null || selectedDevice2 != null) && !isRecording, + modifier = Modifier.fillMaxWidth() + ) { + Text("Connect") + } + } + } + + // --- Section 3: Recording Controls --- + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { if (isRecording) viewModel.stopRecording() else viewModel.startRecording() }, + enabled = (connectionStatus1 == "Connected" || connectionStatus2 == "Connected") && + ((numberOfParticipants == 2 && groupId.isNotBlank()) || + (numberOfParticipants == 1 && participantId.isNotBlank())), + colors = ButtonDefaults.buttonColors( + containerColor = if (isRecording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(if (isRecording) Icons.Filled.Stop else Icons.Filled.PlayArrow, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(if (isRecording) "Stop Recording" else "Start Recording") + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + if (isIntervalRunning) { + viewModel.markTimestamp("interval_end") + viewModel.isIntervalRunning.value = false + } else { + viewModel.markTimestamp("interval_start") + viewModel.isIntervalRunning.value = true + } + }, + enabled = isRecording, + modifier = Modifier.weight(1f) + ) { + Text(if (isIntervalRunning) "Stop Interval" else "Start Interval") + } + Button( + onClick = { viewModel.markTimestamp("manual_mark") }, + enabled = isRecording, + modifier = Modifier.weight(1f) + ) { + Text("Mark Timestamp") + } + } + } + } + + // --- Section 4: Event Log Console --- + LogConsole(logMessages = logMessages, modifier = Modifier.weight(1f)) + Text( + text = "Developed by Tom Hempel", + fontSize = 12.sp, + color = LocalContentColor.current.copy(alpha = 0.7f) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeviceSelectionDropdown( + label: String, + options: List, + selectedOption: BleDevice?, + onOptionSelected: (BleDevice) -> Unit, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + enabled: Boolean, + status: String, + hrValue: Int +) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + ExposedDropdownMenuBox( + expanded = expanded && enabled, + onExpandedChange = onExpandedChange, + modifier = Modifier.weight(1f) + ) { + TextField( + modifier = Modifier.menuAnchor().fillMaxWidth(), + readOnly = true, + value = selectedOption?.name ?: if(enabled) "Select a device" else "Scan first", + onValueChange = {}, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + enabled = enabled + ) + ExposedDropdownMenu( + expanded = expanded && enabled, + onDismissRequest = { onExpandedChange(false) }, + ) { + options.forEach { device -> + if (device.address.isNotEmpty()) { + DropdownMenuItem( + text = { Text(device.name) }, + onClick = { + onOptionSelected(device) + onExpandedChange(false) + } + ) + } + } + } + } + Spacer(Modifier.width(16.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(100.dp)) { + val statusColor = when(status) { + "Connected" -> Color(0xFF2E7D32) // Dark Green + "Error" -> MaterialTheme.colorScheme.error + else -> LocalContentColor.current.copy(alpha = 0.7f) + } + Text(status, fontSize = 14.sp, color = statusColor, fontWeight = FontWeight.Bold) + + if (status == "Connected") { + Text( + text = "$hrValue BPM", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +@Composable +fun LogConsole(logMessages: List, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(logMessages.size) { + if (logMessages.isNotEmpty()) { + coroutineScope.launch { + listState.animateScrollToItem(0) + } + } + } + + OutlinedCard(modifier.fillMaxWidth()) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Event Log", style = MaterialTheme.typography.titleMedium) + Divider(modifier = Modifier.padding(vertical = 4.dp)) + LazyColumn(state = listState, reverseLayout = true, modifier = Modifier.fillMaxSize()) { + items(logMessages) { msg -> + val color = when { + msg.contains("ERROR") -> MaterialTheme.colorScheme.error + msg.contains("Connected") || msg.contains("Recording started") -> Color(0xFF2E7D32) // Dark Green + msg.contains("Stopping") || msg.contains("Disconnected") -> Color(0xFFC62828) // Dark Red + else -> LocalContentColor.current + } + Text( + text = msg, + color = color, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(bottom = 2.dp) + ) + } + } + } + } +} + +@Composable +fun ParticipantSelectionDialog( + onDismiss: (Int) -> Unit +) { + AlertDialog( + onDismissRequest = { /* Do nothing, force selection */ }, + title = { Text("Select Number of Participants") }, + text = { Text("Please select the number of participants for this recording session.") }, + confirmButton = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Button( + onClick = { onDismiss(1) }, + modifier = Modifier.weight(1f).padding(end = 8.dp) + ) { + Text("1 Participant") + } + Button( + onClick = { onDismiss(2) }, + modifier = Modifier.weight(1f).padding(start = 8.dp) + ) { + Text("2 Participants") + } + } + }, + dismissButton = null // No dismiss button to force selection + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/labrecorder/ParticipantSelectionActivity.kt b/app/src/main/java/com/tomhempel/labrecorder/ParticipantSelectionActivity.kt new file mode 100644 index 0000000..72bc264 --- /dev/null +++ b/app/src/main/java/com/tomhempel/labrecorder/ParticipantSelectionActivity.kt @@ -0,0 +1,181 @@ +package com.tomhempel.labrecorder + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tomhempel.labrecorder.ui.theme.LabRecorderTheme + +class ParticipantSelectionActivity : ComponentActivity() { + private var showExitDialog by mutableStateOf(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + LabRecorderTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + if (showExitDialog) { + AlertDialog( + onDismissRequest = { showExitDialog = false }, + title = { Text("Exit App") }, + text = { Text("Do you want to exit the app?") }, + confirmButton = { + TextButton(onClick = { + showExitDialog = false + finish() + }) { + Text("Yes") + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false }) { + Text("No") + } + } + ) + } + + ParticipantSelectionScreen( + onSelectionMade = { numberOfParticipants -> + val intent = Intent(this, MainActivity::class.java).apply { + putExtra("NUMBER_OF_PARTICIPANTS", numberOfParticipants) + } + startActivity(intent) + finish() // Close this activity + }, + onInspectData = { + val intent = Intent(this, DataInspectionActivity::class.java) + startActivity(intent) + } + ) + } + } + } + } + + override fun onBackPressed() { + showExitDialog = true + } +} + +@Composable +fun ParticipantSelectionScreen( + onSelectionMade: (Int) -> Unit, + onInspectData: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Logo + Image( + painter = painterResource(id = R.mipmap.logo), + contentDescription = "App Logo", + modifier = Modifier.size(120.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Title + Text( + text = "Lab Recorder", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + // Description + Text( + text = "Select the number of participants for this recording session", + fontSize = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Selection Buttons + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { onSelectionMade(1) }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 32.dp) + ) { + Text("Single Participant", fontSize = 18.sp) + } + + Button( + onClick = { onSelectionMade(2) }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 32.dp) + ) { + Text("Two Participants", fontSize = 18.sp) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Developed by Tom Hempel", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = LocalContentColor.current.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + } + + // Divider and Data Inspection Section + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 30.dp) // Added extra padding at the bottom + ) { + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + thickness = 2.dp + ) + + OutlinedButton( + onClick = onInspectData, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 32.dp) + ) { + Text("Inspect Recorded Data", fontSize = 16.sp) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/labrecorder/TimeNormalizedPlotActivity.kt b/app/src/main/java/com/tomhempel/labrecorder/TimeNormalizedPlotActivity.kt new file mode 100644 index 0000000..917948f --- /dev/null +++ b/app/src/main/java/com/tomhempel/labrecorder/TimeNormalizedPlotActivity.kt @@ -0,0 +1,452 @@ +package com.tomhempel.labrecorder + +import android.graphics.Color +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.Legend +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import java.io.File +import java.io.FileReader +import java.io.BufferedReader +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.cardview.widget.CardView +import androidx.core.view.updateLayoutParams +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.listener.OnChartValueSelectedListener +import com.github.mikephil.charting.interfaces.datasets.ILineDataSet + +class TimeNormalizedPlotActivity : AppCompatActivity() { + private lateinit var hrChart: LineChart + private lateinit var rrChart: LineChart + private val intervalColors = listOf( + Color.argb(128, 128, 128, 128), // Gray + Color.argb(128, 128, 64, 64), // Dark Red + Color.argb(128, 64, 128, 64), // Dark Green + Color.argb(128, 64, 64, 128), // Dark Blue + Color.argb(128, 128, 128, 64), // Dark Yellow + Color.argb(128, 128, 64, 128) // Dark Purple + ) + + data class TimestampEvent( + val timestamp: Long, + val eventType: String + ) + + data class DataPoint( + val timestamp: Long, + val value: Float + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Create ScrollView for scrollable content + val scrollView = android.widget.ScrollView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + isFillViewport = true + } + + // Create layout programmatically + val rootLayout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + setPadding(16, 32, 16, 32) // Added top and bottom padding + } + + // Create stats cards container + val statsContainer = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, 0, 0, 16) + } + weightSum = 2f + } + + // Create stats cards for each participant + val participant1Stats = CardView(this).apply { + layoutParams = LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ).apply { + setMargins(0, 0, 8, 0) + } + radius = 12f + cardElevation = 8f + setCardBackgroundColor(Color.parseColor("#1F1F1F")) + } + + val participant2Stats = CardView(this).apply { + layoutParams = LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ).apply { + setMargins(8, 0, 0, 0) + } + radius = 12f + cardElevation = 8f + setCardBackgroundColor(Color.parseColor("#1F1F1F")) + } + + // Create TextViews for stats with better styling + val participant1Text = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + setPadding(20, 20, 20, 20) + textSize = 14f + setTextColor(Color.WHITE) + setLineSpacing(4f, 1.2f) + } + + val participant2Text = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + setPadding(20, 20, 20, 20) + textSize = 14f + setTextColor(Color.WHITE) + setLineSpacing(4f, 1.2f) + } + + participant1Stats.addView(participant1Text) + participant2Stats.addView(participant2Text) + statsContainer.addView(participant1Stats) + statsContainer.addView(participant2Stats) + rootLayout.addView(statsContainer) + + // Create HR Chart Card + val hrCard = CardView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1f + ).apply { + setMargins(0, 0, 0, 16) + } + radius = 8f + cardElevation = 4f + } + hrChart = LineChart(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + description.isEnabled = false + } + hrCard.addView(hrChart) + rootLayout.addView(hrCard) + + // Create RR Chart Card + val rrCard = CardView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1f + ).apply { + setMargins(0, 0, 0, 16) + } + radius = 8f + cardElevation = 4f + } + rrChart = LineChart(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + description.isEnabled = false + } + rrCard.addView(rrChart) + rootLayout.addView(rrCard) + + // Add ScrollView to the content + scrollView.addView(rootLayout) + setContentView(scrollView) + + // Configure charts + setupChart(hrChart, "Heart Rate (BPM)") + setupChart(rrChart, "RR Interval (ms)") + + // Load and display data + val recordingPath = intent.getStringExtra("RECORDING_PATH") ?: return + loadAndDisplayData(recordingPath, participant1Text, participant2Text) + } + + private fun setupChart(chart: LineChart, yAxisLabel: String) { + chart.apply { + // Disable interactions + setTouchEnabled(false) + setPinchZoom(false) + setScaleEnabled(false) + isDragEnabled = false + + // Configure legend + legend.apply { + horizontalAlignment = Legend.LegendHorizontalAlignment.CENTER + verticalAlignment = Legend.LegendVerticalAlignment.TOP + orientation = Legend.LegendOrientation.HORIZONTAL + setDrawInside(false) + form = Legend.LegendForm.LINE + } + + // Configure axes + xAxis.apply { + position = XAxis.XAxisPosition.BOTTOM + setDrawGridLines(true) + labelRotationAngle = 0f + axisMinimum = 0f + textSize = 12f + valueFormatter = object : com.github.mikephil.charting.formatter.ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return "${value.toInt()}s" + } + } + } + + axisLeft.apply { + setDrawGridLines(true) + textSize = 12f + // Fix: Use description instead of axisLabel + description.text = yAxisLabel + } + + axisRight.isEnabled = false + + // Add padding for better visibility + setExtraOffsets(8f, 16f, 8f, 8f) + } + } + + private fun loadAndDisplayData( + recordingPath: String, + participant1Text: TextView, + participant2Text: TextView + ) { + val recordingDir = File(recordingPath) + val timestampsFile = File(recordingDir, "timestamps.csv") + + // Load global events + val events = loadTimestamps(timestampsFile) + val firstTimestamp = events.minOfOrNull { it.timestamp } ?: 0L + + // Load participant data + val participants = listOf("Participant_1", "Participant_2") + val participantColors = listOf(Color.BLUE, Color.RED) + + val hrDataSets = mutableListOf() + val rrDataSets = mutableListOf() + val statsBuilders = Array(2) { StringBuilder() } + + participants.forEachIndexed { index, participant -> + val participantDir = File(recordingDir, participant) + val hrFile = File(participantDir, "hr.csv") + val rrFile = File(participantDir, "rr.csv") + + statsBuilders[index].append("$participant\n\n") + + // Load HR data + val hrData = loadDataPoints(hrFile) + if (hrData.isNotEmpty()) { + val hrEntries = hrData.map { + Entry( + (it.timestamp - firstTimestamp) / 1000f, + it.value + ) + } + val hrDataSet = LineDataSet(hrEntries, "$participant HR").apply { + color = participantColors[index] + setDrawCircles(true) + circleRadius = 2f + circleColors = listOf(participantColors[index]) + lineWidth = 2f + mode = LineDataSet.Mode.LINEAR + } + hrDataSets.add(hrDataSet) + + // Calculate statistics + statsBuilders[index].append("Heart Rate:\n") + statsBuilders[index].append(" Min: ${hrData.minOf { it.value }.toInt()} BPM\n") + statsBuilders[index].append(" Max: ${hrData.maxOf { it.value }.toInt()} BPM\n") + statsBuilders[index].append(" Avg: ${hrData.map { it.value }.average().toInt()} BPM\n\n") + } + + // Load RR data + val rrData = loadDataPoints(rrFile) + if (rrData.isNotEmpty()) { + val rrEntries = rrData.map { + Entry( + (it.timestamp - firstTimestamp) / 1000f, + it.value + ) + } + val rrDataSet = LineDataSet(rrEntries, "$participant RR").apply { + color = participantColors[index] + setDrawCircles(true) + circleRadius = 2f + circleColors = listOf(participantColors[index]) + lineWidth = 2f + mode = LineDataSet.Mode.LINEAR + } + rrDataSets.add(rrDataSet) + + // Calculate statistics + statsBuilders[index].append("RR Intervals:\n") + statsBuilders[index].append(" Min: ${rrData.minOf { it.value }.toInt()} ms\n") + statsBuilders[index].append(" Max: ${rrData.maxOf { it.value }.toInt()} ms\n") + statsBuilders[index].append(" Avg: ${rrData.map { it.value }.average().toInt()} ms") + } + } + + // Update statistics + participant1Text.text = statsBuilders[0].toString() + participant2Text.text = statsBuilders[1].toString() + + // Set chart data + hrChart.setData(LineData(hrDataSets)) + rrChart.setData(LineData(rrDataSets)) + + // Add event markers and intervals + addEventMarkersAndIntervals(events, firstTimestamp, hrChart) + addEventMarkersAndIntervals(events, firstTimestamp, rrChart) + + // Refresh charts + hrChart.invalidate() + rrChart.invalidate() + } + + private fun loadTimestamps(file: File): List { + if (!file.exists()) return emptyList() + + return try { + BufferedReader(FileReader(file)).useLines { lines -> + lines.drop(1) // Skip header + .map { line -> + val parts = line.split(",") + TimestampEvent( + timestamp = parts[0].toLong(), + eventType = parts[1] + ) + }.toList() + } + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + private fun loadDataPoints(file: File): List { + if (!file.exists()) return emptyList() + + return try { + BufferedReader(FileReader(file)).useLines { lines -> + lines.drop(1) // Skip header + .map { line -> + val parts = line.split(",") + DataPoint( + timestamp = parts[0].toLong(), + value = parts[1].toFloat() + ) + }.toList() + } + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + private fun addEventMarkersAndIntervals( + events: List, + firstTimestamp: Long, + chart: LineChart + ) { + var currentIntervalStart: Float? = null + var intervalIndex = 0 + val intervalDataSets = mutableListOf() + + events.forEach { event -> + val normalizedTime = (event.timestamp - firstTimestamp) / 1000f + + when (event.eventType) { + "manual_mark" -> { + // Add vertical line for manual mark - white and prominent + val line = com.github.mikephil.charting.components.LimitLine(normalizedTime).apply { + lineWidth = 1f + lineColor = Color.WHITE + enableDashedLine(15f, 8f, 0f) + label = "" // No label + } + chart.xAxis.addLimitLine(line) + } + "interval_start" -> { + currentIntervalStart = normalizedTime + } + "interval_end" -> { + currentIntervalStart?.let { start -> + // Create filled area for interval using a background dataset + var color = intervalColors[intervalIndex % intervalColors.size] + + // Create entries for the interval area + val entries = listOf( + Entry(start, chart.axisLeft.axisMinimum), + Entry(start, chart.axisLeft.axisMaximum), + Entry(normalizedTime, chart.axisLeft.axisMaximum), + Entry(normalizedTime, chart.axisLeft.axisMinimum) + ) + + val intervalDataSet = LineDataSet(entries, "Interval ${intervalIndex + 1}").apply { + setDrawFilled(true) + fillColor = color + fillAlpha = 128 + color = Color.TRANSPARENT + setDrawCircles(false) + setDrawValues(false) + isHighlightEnabled = false + axisDependency = YAxis.AxisDependency.LEFT + } + + intervalDataSets.add(intervalDataSet) + intervalIndex++ + } + currentIntervalStart = null + } + } + } + + // Add interval datasets to the chart + if (intervalDataSets.isNotEmpty()) { + // Get current datasets + val currentDataSets = chart.data?.dataSets?.toMutableList() ?: mutableListOf() + + // Create combined dataset list + val allDataSets = mutableListOf() + allDataSets.addAll(intervalDataSets) + allDataSets.addAll(currentDataSets) + + // Create and set new data + val newData = LineData(allDataSets) + chart.setData(newData) + chart.invalidate() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Color.kt b/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Color.kt new file mode 100644 index 0000000..e9cead2 --- /dev/null +++ b/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.tomhempel.labrecorder.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Theme.kt b/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Theme.kt new file mode 100644 index 0000000..c83a0de --- /dev/null +++ b/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package com.tomhempel.labrecorder.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun LabRecorderTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Type.kt b/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Type.kt new file mode 100644 index 0000000..b6d79fc --- /dev/null +++ b/app/src/main/java/com/tomhempel/labrecorder/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.tomhempel.labrecorder.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_analyse.xml b/app/src/main/res/layout/activity_analyse.xml new file mode 100644 index 0000000..d804be8 --- /dev/null +++ b/app/src/main/res/layout/activity_analyse.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/content_analyse.xml b/app/src/main/res/layout/content_analyse.xml new file mode 100644 index 0000000..abc4bae --- /dev/null +++ b/app/src/main/res/layout/content_analyse.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_first.xml b/app/src/main/res/layout/fragment_first.xml new file mode 100644 index 0000000..44baecd --- /dev/null +++ b/app/src/main/res/layout/fragment_first.xml @@ -0,0 +1,35 @@ + + + + + +