Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@
The app is actively developed by the neuropsychology group of [Stefan Debener](https://uol.de/neuropsychologie) in Oldenburg, Germany.

#### Active Developers
* **Paul Maanen** - [pmaanen](https://github.com/pmaanen)
* **Sarah Blum** - [sarah-blum](https://github.com/s4rify)

#### Previous Developers
* **Paul Maanen** - [pmaanen](https://github.com/pmaanen)

## Acknowledgements
* [liblsl](https://github.com/sccn/liblsl), used under MIT license
* [liblsl-Java](https://github.com/labstreaminglayer/liblsl-Java), used under MIT license
Expand Down
19 changes: 9 additions & 10 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
//apply plugin: 'kotlin-android-extensions'

base.archivesName = "VIEWA-01"

android {
compileSdk 34
compileSdk 36
namespace "de.uol.neuropsy.viewa"
ndkVersion "25.2.9519653"
ndkVersion '29.0.14206865'
defaultConfig {
vectorDrawables.useSupportLibrary = true
applicationId "de.uol.neuropsy.viewa"
minSdkVersion 30
targetSdkVersion 31
minSdkVersion 33
targetSdkVersion 36
versionCode 1
versionName "01"
setProperty("archivesBaseName", "VIEWA-$versionName")
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
versionNameSuffix='0.9'
}
}
Expand All @@ -32,9 +34,6 @@ android {
targetCompatibility JavaVersion.VERSION_1_8
}

composeOptions {
kotlinCompilerExtensionVersion "1.4.2"
}

buildFeatures {
viewBinding true
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down
32 changes: 29 additions & 3 deletions app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package de.uol.neuropsy.viewa.service

import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Binder
import android.os.IBinder
import android.util.Log
Expand All @@ -20,6 +21,10 @@ class LSLService : LifecycleService() {

private val timeoutMs = 500.0 // half-second

// Multicast lock: prevents Android's WiFi chip from filtering incoming multicast
// UDP packets (stream discovery responses) when WiFi is connected.
private var multicastLock: WifiManager.MulticastLock? = null

sealed class ServiceEvent {
data class DataSample(val streamName: String,val timestamp:Double, val sample: FloatArray) : ServiceEvent() {
override fun equals(other: Any?): Boolean {
Expand Down Expand Up @@ -63,8 +68,23 @@ class LSLService : LifecycleService() {
private val localBinder = LocalBinder()

override fun onBind(intent: Intent): IBinder {
super.onBind(intent) // important for LifecycleService
return localBinder // return your binder here
super.onBind(intent)
return localBinder
}

override fun onCreate() {
super.onCreate()
val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as? WifiManager
multicastLock = wifiManager?.createMulticastLock("VIEWA_LSL")?.also {
it.setReferenceCounted(false)
it.acquire()
}
}

override fun onDestroy() {
multicastLock?.release()
multicastLock = null
super.onDestroy()
}

override fun onUnbind(intent: Intent?): Boolean {
Expand All @@ -78,7 +98,13 @@ class LSLService : LifecycleService() {
}

fun startInlet(streamName : String){
val info = LSL.resolve_stream("name", streamName).firstOrNull() ?: return
// Resolve with a 5-second timeout. Prefer matching by source_id (unique per device)
// so that two sensors with the same name are handled correctly. Fall back to name only.
val candidates = LSL.resolve_stream("name", streamName, 1, 5.0)
val info = candidates.firstOrNull() ?: run {
Log.w("LSLService", "Could not find stream '$streamName' within timeout")
return
}
val inlet = StreamInlet(info)
val job = lifecycleScope.launch(Dispatchers.IO) {
Log.i("LSLService","Emitting config for ${info.name()}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
Expand All @@ -22,6 +23,11 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Enable edge-to-edge so AppBarLayout can extend behind the status bar
WindowCompat.setDecorFitsSystemWindows(window, false)
// Both themes use a dark-blue toolbar → white status bar icons in both modes
WindowCompat.getInsetsController(window, window.decorView)
.isAppearanceLightStatusBars = false
// Find the toolbar
val toolbar: MaterialToolbar = findViewById(R.id.toolbar)
// Set it as the support ActionBar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ class FullScreenPlotFragment : Fragment(R.layout.fragment_fullscreen_plot) {
// chart setup (axes, descr, etc)…
chart.description.isEnabled = false
chart.axisRight.isEnabled = false
// Resolve label colour from the current theme so it adapts to light/dark mode
// Pick label colour based on current night-mode setting
val nightMask = requireContext().resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK
val labelColor = if (nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES)
0xFFCCCCCC.toInt() // light grey for dark mode
else
android.graphics.Color.DKGRAY // dark grey for light mode
chart.xAxis.textColor = labelColor
chart.axisLeft.textColor = labelColor
chart.legend.textColor = labelColor
var channelNames : List<String> = emptyList()
// observe live data updates for this stream
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package de.uol.neuropsy.viewa.ui.plot

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineDataSet
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet
import de.uol.neuropsy.viewa.service.LSLService
import de.uol.neuropsy.viewa.utils.ColorPalette
import edu.ucsd.sccn.LSL
Expand Down Expand Up @@ -35,6 +33,9 @@ class LivePlotViewModel : ViewModel() {
private var pausePlotting = false
private var allTimeMin = mutableMapOf<String, Float>()
private var allTimeMax = mutableMapOf<String, Float>()
// Baseline timestamp per stream: subtract this from all timestamps so X values
// stay small enough to be represented accurately as Float (LSL timestamps are ~1e9 seconds)
private var timestampBaseline = mutableMapOf<String, Double>()
private var job: Job? = null
private val buffers = mutableMapOf<String, Map<Int, ArrayDeque<Entry>>>()
private val _uiState =
Expand Down Expand Up @@ -68,15 +69,20 @@ class LivePlotViewModel : ViewModel() {

private fun handleDataEvent(sampleEv: LSLService.ServiceEvent.DataSample) {
val name = sampleEv.streamName
val t = sampleEv.timestamp.toFloat()
// Use a per-stream baseline so that X values start near 0 and fit accurately in Float
val baseline = timestampBaseline.getOrPut(name) { sampleEv.timestamp }
val t = (sampleEv.timestamp - baseline).toFloat()
val ys = sampleEv.sample

val chBufs = buffers[name]
?: return // If this stream is not yet configured, drop the sample
?: run {
return
}

// add each channels new Entry
// add each channel's new Entry
chBufs.forEach { (i, buf) ->
buf.addLast(Entry(t, ys[i]))
// Remove every entry older than 5 seconds
// Remove every entry older than bufferSizeInSeconds
// TODO make the timeout configurable
buf.removeIf { t -> buf.last().x - t.x > bufferSizeInSeconds }
}
Expand All @@ -89,6 +95,7 @@ class LivePlotViewModel : ViewModel() {
val currentMax = allValues.maxOrNull() ?: 0f
allTimeMin[name] = min(allTimeMin[name]!!, currentMin)
allTimeMax[name] = max(allTimeMax[name]!!, currentMax)

val cp = ColorPalette()

// Build a ChartUiState for each stream this is inefficient because we rebuild the
Expand Down Expand Up @@ -123,6 +130,7 @@ class LivePlotViewModel : ViewModel() {
.toMutableMap()
allTimeMin[streamName] = Float.POSITIVE_INFINITY
allTimeMax[streamName] = Float.NEGATIVE_INFINITY
timestampBaseline.remove(streamName)
}

fun updateSelection(newStreams: Set<String>) {
Expand All @@ -140,6 +148,7 @@ class LivePlotViewModel : ViewModel() {
fun resetLimits(name: String) {
allTimeMax[name] = Float.NEGATIVE_INFINITY
allTimeMin[name] = Float.POSITIVE_INFINITY
timestampBaseline.remove(name)
}

fun toggleVisible(streamName: String, channelIdx: Int, shouldBeVisible:Boolean){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,41 @@ class StreamPlotAdapter(
// Set the text of the title TV
binding.streamTitle.text = streamName
// Fetch the latest DataSets for this stream
val dataSets = viewModel.uiState.value[streamName]?.entries?: emptyList()
val dataSets = viewModel.uiState.value[streamName]?.entries ?: emptyList()
Log.d("LivePlot", "[Adapter] $streamName datasets=${dataSets.size} " +
dataSets.mapIndexed { i, ds ->
"Ch$i: entries=${ds.entryCount} visible=${ds.isVisible} " +
"yRange=[${if (ds.entryCount > 0) ds.yMin else Float.NaN}, ${if (ds.entryCount > 0) ds.yMax else Float.NaN}]"
}.joinToString(" | ")
)
binding.streamChart.description=Description().apply {isEnabled=false}
binding.streamChart.axisRight.isEnabled=false
// Apply to the chart
// Pick label colour based on current night-mode setting
val nightMask = holder.itemView.context.resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK
val labelColor = if (nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES)
0xFFCCCCCC.toInt() // light grey for dark mode
else
android.graphics.Color.DKGRAY // dark grey for light mode
binding.streamChart.xAxis.textColor = labelColor
binding.streamChart.axisLeft.textColor = labelColor
binding.streamChart.legend.textColor = labelColor
binding.streamChart.apply {
data = LineData(*dataSets.toTypedArray())
viewModel.uiState.value[streamName]?.yMax?.let { axisLeft.axisMaximum=it }
viewModel.uiState.value[streamName]?.yMin?.let { axisLeft.axisMinimum=it }
// Only apply axis limits when we have finite values (guard against initial ±Infinity)
val yMin = viewModel.uiState.value[streamName]?.yMin ?: Float.NaN
val yMax = viewModel.uiState.value[streamName]?.yMax ?: Float.NaN
if (yMin.isFinite() && yMax.isFinite()) {
val range = yMax - yMin
val padding = if (range > 0f) range * 0.1f else Math.abs(yMax) * 0.1f + 1f
axisLeft.axisMaximum = yMax + padding
axisLeft.axisMinimum = yMin - padding
} else {
axisLeft.resetAxisMaximum()
axisLeft.resetAxisMinimum()
}
// Auto-scroll to the latest data so the chart viewport follows incoming samples
moveViewToX(data?.xMax ?: 0f)
notifyDataSetChanged()
invalidate()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ class StreamListAdapter(

companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<StreamItem>() {
// Use uid — not name — so two outlets sharing the same name are distinct rows
override fun areItemsTheSame(old: StreamItem, new: StreamItem) =
old.name == new.name
old.uid == new.uid
override fun areContentsTheSame(old: StreamItem, new: StreamItem) =
old == new
}
Expand All @@ -66,8 +67,12 @@ class StreamListAdapter(

/**
* Data class representing a single stream in the list.
* [uid] is the LSL outlet UID — guaranteed unique per outlet instance — used
* as the DiffUtil identity key so that two streams sharing the same name are
* still treated as distinct rows.
*/
data class StreamItem(
val uid: String,
val name: String,
var isChecked: Boolean,
val subtitle : String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,21 @@ class StreamSelectionViewModel : ViewModel() {
/**
* Performs LSL discovery on a background thread,
* updating availableStreams when done.
*
* wait_time is set to 3 s (instead of the 1 s default) so that all outlets on
* a WiFi network have time to re-announce themselves before we collect results.
*/
fun refreshAvailableStreams() {
viewModelScope.launch(Dispatchers.IO) {
val infos=LSL.resolve_streams()
val infos = LSL.resolve_streams(3.0)
val items = infos.map { info ->
val host = info.hostname().takeIf { it.isNotEmpty() } ?: "unknown host"
StreamItem(
uid = info.uid(),
name = info.name(),
isChecked = _selectedStreams.value.contains(info.name()),
subtitle = "${info.channel_count()} ch ${info.type()} @ ${if (info.nominal_srate()==LSL.IRREGULAR_RATE) "irregular rate" else info.nominal_srate()}"
subtitle = "${info.channel_count()} ch ${info.type()} @ " +
"${if (info.nominal_srate() == LSL.IRREGULAR_RATE) "irregular" else info.nominal_srate()} Hz [$host]"
)
}
withContext(Dispatchers.Main) {
Expand Down
25 changes: 19 additions & 6 deletions app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Color.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ package de.uol.neuropsy.viewa.ui.theme

import androidx.compose.ui.graphics.Color

val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
// Primary – deep ocean blue
val Blue10 = Color(0xFF001F33)
val Blue20 = Color(0xFF003D66)
val Blue40 = Color(0xFF0077B6) // main primary (matches toolbar)
val Blue80 = Color(0xFF90CAF9) // primary in dark mode
val Blue90 = Color(0xFFBBDEFB) // primary container

val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// Secondary – bright teal
val Teal40 = Color(0xFF00838F)
val Teal80 = Color(0xFF80DEEA)
val Teal90 = Color(0xFFB2EBF2)

// Tertiary – warm amber accent
val Amber40 = Color(0xFFB45309)
val Amber80 = Color(0xFFFFCC80)

// Neutrals
val Neutral10 = Color(0xFF1A1C1E)
val Neutral90 = Color(0xFFE1E2E8)
val Neutral99 = Color(0xFFFAFCFF)
Loading
Loading