diff --git a/.github/workflows/android_build.yml b/.github/workflows/android_build.yml index e7e3e6dc..35febed7 100644 --- a/.github/workflows/android_build.yml +++ b/.github/workflows/android_build.yml @@ -3,78 +3,57 @@ name: Android Build on: push: branches: - - master # Change this to your main branch name (e.g., master) - - development + - '**' # Trigger on push to any branch + jobs: build-debug: - name: Build debug + # Build debug APK for all feature branches and not master + if: github.ref_name != 'master' runs-on: ubuntu-latest - if: github.ref =='refs/heads/development' - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Setup JDK - uses: actions/setup-java@v3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - with: - gradle-version: 7.4.2 - - - name: Setup Android NDK - uses: nttld/setup-ndk@v1.2.0 - with: - ndk-version: r25c - - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew + - uses: gradle/actions/setup-gradle@v4 + with: { gradle-version: 7.4.2 } + - uses: nttld/setup-ndk@v1.2.0 + with: { ndk-version: r25c } + - run: chmod +x ./gradlew - name: Build debug APK run: ./gradlew assembleDebug - - name: Upload APK artifact + - name: Upload debug APK uses: actions/upload-artifact@v4 with: - name: SENDA-debug # Change this to your desired artifact name - path: ./app/build/outputs/apk/debug/*.apk + name: SENDA-debug + path: app/build/outputs/apk/debug/*.apk - build-master: - name: Build master + build-release: + # Build release APK ONLY for master branch + if: github.ref_name == 'master' runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Setup JDK - uses: actions/setup-java@v3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: distribution: temurin - java-version: '17' # Change this to the required Java version for your Android project - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - with: - gradle-version: 7.4.2 - - - name: Setup Android NDK - uses: nttld/setup-ndk@v1.2.0 - with: - ndk-version: r25c - - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + with: { gradle-version: 7.4.2 } + - uses: nttld/setup-ndk@v1.2.0 + with: { ndk-version: r25c } + - run: chmod +x ./gradlew - name: Build release APK run: ./gradlew assembleRelease - name: Sign app APK uses: r0adkll/sign-android-release@v1 - # ID used to access action output id: sign_app with: releaseDirectory: app/build/outputs/apk/release @@ -83,12 +62,10 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (33.0.0) -- optional BUILD_TOOLS_VERSION: "34.0.0" - - name: Upload APK artifact + - name: Upload release APK uses: actions/upload-artifact@v4 with: - name: SENDA-release # Change this to your desired artifact name - path: ${{steps.sign_app.outputs.signedReleaseFile}} # Change the path to the location of your APK file - + name: SENDA-release + path: ${{ steps.sign_app.outputs.signedReleaseFile }} diff --git a/.idea/misc.xml b/.idea/misc.xml index 6e49bc5f..e789a87e 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,7 @@ + - + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..75d5bc38 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to SENDA are documented in this file. + +## [v2.0.0] – 2026-05-22 + +This is a major release representing a complete overhaul of the SENDA codebase +and a significant expansion of features since v1.3.0. + +### Breaking changes +- The app has been **fully rewritten in Kotlin** (previously Java). The minimum + supported Android version remains Android 11. +- The internal package structure was reorganised for improved modularity. + +### New features +- **Dark theme** support (follows system setting). +- **In-app tutorial** with multiple pages guiding new users through setup. +- **About dialog** accessible from the action bar. +- **Movella DOT** sensor support: connects once on discovery to resolve the + human-readable name, then reconnects for measurement. + +### Improvements & refactoring +- Migrated to **MVVM architecture** with ViewModel and Repository pattern. +- Foreground service and wakelock handling refactored for `targetSdk 34+`. +- Permissions handling streamlined; new transient *starting* / *stopping* + UI states provide clearer feedback. +- **16 KB ELF page-size compatibility**: native code compiled with the required + alignment flags (Google Play requirement since November 2025). Note that the + bundled Movella SDK library is not yet compatible. +- Main layout simplified to `LinearLayout`; old landscape / splash layouts and + unused preference XMLs removed. +- `compileSdk` / `targetSdk` raised to **35**; NDK updated to **r28**. +- Gradle plugin updated to **8.1.2 / 8.10**; `setup-gradle` action bumped; CI + workflow split into separate debug and release APK jobs. +- LSL service start/stop made concurrency-safe. +- LocationBridge crash on startup fixed. +- MainActivity crash fixed by lazy-initialising the ViewModel. +- Sensor outlet race condition fixed (sensor stream now starts only after the + LSL outlet is initialised). +- Unit test added for `MainViewModel`. +- App correctly stops the foreground service when swiped away from recents. + +### UI / UX +- Stream availability status text updated. +- Layout margins adjusted in `activity_main.xml`. +- Settings/gear menu icon removed (no configurable settings at this time). +- Tutorial layout constraints and text corrected. + +### Build & CI +- APK signed correctly via GitHub Actions using repository secrets. +- Separate CI jobs for debug builds (feature branches) and signed release APKs + (master branch). +- Signing configuration now checks for keystore existence before applying + settings, preventing local-build failures. + +--- + +## [v1.3.0] – see [NeuropsyOL/SENDA](https://github.com/NeuropsyOL/SENDA/releases/tag/v1.3.0) + +Previous releases are tagged in the public repository at +https://github.com/NeuropsyOL/SENDA/releases. + diff --git a/README.md b/README.md index 8fbba55d..db4d59a3 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ --- # SENDA Android Application to stream out sensor data directly from the Phone -SENDA(Sensor Data Streamer) is an android application to stream real time sensor reading using LSL (Lab Streaming Layer). Along with the sensors it streams real time audio as well. This is a fork from the original author's Git [Ali Ayub Khan's SENDA](https://github.com/AliAyub007/SENDA), which contains additional features and support for Samsung phones. +SENDA (Sensor Data Streamer) is an android application to stream real-time sensor reading using LSL (Lab Streaming Layer). Along with the sensors it streams real-time audio as well. This is a fork from the original author's Git [Ali Ayub Khan's SENDA](https://github.com/AliAyub007/SENDA), which contains additional features and support for Samsung phones. ## Features The following sensors are included: - Accelerometer +- Gyroscope - Light - Proximity - Gravity @@ -16,6 +17,10 @@ The following sensors are included: - Location - Movella DOT (via Bluetooth) +Additional capabilities: +- Dark theme (follows system setting) +- In-app setup tutorial + ## Data formats ### Location @@ -35,10 +40,10 @@ For details about the data format and the orientation of the axes, see the Movel ## Getting Started -#### Installation -Download the [latest release](https://github.com/NeuropsyOL/SENDA/releases/latest) and install the apk on your smartphone or tablet running Android 11 or higher. +### Installation +Download the [latest release](https://github.com/NeuropsyOL/SENDA/releases/latest) and install the APK on your smartphone or tablet running Android 11 or higher. -#### Usage: +### Usage Upon launching the app, the user is presented with a main screen displaying a list of available device sensors. Each sensor can be individually selected using check buttons, allowing the user to choose which data to stream. The list of sensors can be refreshed with a swipe-down gesture. Some sensors—such as audio classification and recording, GPS, and Movella sensors—require special permissions to function properly. When necessary, a permission request dialog is shown when a sensor is selected. GPS requires access to precise location at all times in order to operate correctly. If a permission is repeatedly denied, Android may block further requests. In such cases, the app opens the system settings screen, allowing the user to grant the required permission manually. @@ -47,25 +52,25 @@ To start streaming, the user must press the **START LSL** button. To stop stream The LSL streams transmitted by SENDA can be recorded using any LSL-compatible application, such as the [LabRecorder](https://github.com/labstreaminglayer/App-LabRecorder) on PC or [RECORDA](https://github.com/NeuropsyOL/RECORDA) on Android. -On newer versions of Android, it may be necessary to prevent the system from limiting the app's processing time to conserve battery. This can be done by navigating to **Settings → Battery optimization ** and disabling battery optimization for SENDA. +On newer versions of Android, it may be necessary to prevent the system from limiting the app's processing time to conserve battery. This can be done by navigating to **Settings → Battery optimization** and disabling battery optimization for SENDA. ## Development -#### Prerequisites: -- Android Studio Giraffe | 2022.3.1 Patch 2 -- Android API 34 SDK platform -- Android NDK 25.2.9519653 +### Prerequisites +- Android Studio Meerkat | 2024.3.1 (or newer) +- Android API 35 SDK platform +- Android NDK r28 (28.0.12433566) - CMake 3.22.1 -Other version may work, the above listed versions are the ones used for development and testing. +Other versions may work, the above listed versions are the ones used for development and testing. -#### Development: +### Development In order to start with development you need to follow these steps: -- Clone this repository -- Open project with Android Studio -- Import the project +- - Clone the repository +- - Open the project with Android Studio +- - Import the project ## Contributing Please feel free to contribute to this project by creating an issue first and then sending a pull request respectively. @@ -76,12 +81,12 @@ Please feel free to contribute to this project by creating an issue first and th * **Paul Maanen** - [pmaanen](https://github.com/pmaanen) ## License -This project is licensed under GNU General Public License License - see the [LICENSE.md](LICENSE.md) file for details +This project is licensed under GNU General Public License - see the [LICENSE.md](LICENSE.md) file for details ## Known Issues -- Sometimes the app complains about mission bluetooth permissions but does the scan for Movella sensors anyway. -- On a new installation multiple restarts of the app might be necessary until it asks for and notices newly granted permissions -- Some phones don't respect the foreground service and wakelock. A possible workaround is to set the app to unlimited background power usage in the app settings. +- Sometimes the app reports missing Bluetooth permissions but does the scan for Movella sensors anyway. +- After a fresh installation, multiple restarts may be required before the app detects newly granted permissions. +- Some devices may not properly honor foreground service or wakelock settings. A potential workaround is to allow unlimited background power usage for the app in the system settings. ## Acknowledgments @@ -90,4 +95,4 @@ This project is licensed under GNU General Public License License - see the [LIC [Google Mediapipe](https://developers.google.com/mediapipe), Apache v2.0 license. ## Cite As: - +- Blum S, Hölle D, Bleichner MG, Debener S. Pocketable Labs for Everyone: Synchronized Multi-Sensor Data Streaming and Recording on Smartphones with the Lab Streaming Layer. Sensors. 2021; 21(23):8135. https://doi.org/10.3390/s21238135 diff --git a/app/build.gradle b/app/build.gradle index b840e764..9dc9e108 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,36 +1,78 @@ plugins { + // This plugin is used to download the yamnet model file id 'de.undercouch.download' + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.parcelize' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -//apply plugin: 'kotlin-android-extensions' +def appVersionMajorMinor = "2.0" +// GITHUB_RUN_NUMBER is set automatically by GitHub Actions on every CI run. +// Locally it is unset, so we fall back to 0 (which triggers the local defaults below). +def ciBuildNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "0").toInteger() +// versionCode must always increase. Offset by 300 so v2.0 CI run #1 (→ 301) +// is greater than all v1.x builds (which used offset 200). +def appVersionCode = ciBuildNumber > 0 ? (300 + ciBuildNumber) : 200 +def appVersionName = ciBuildNumber > 0 ? "${appVersionMajorMinor}.${ciBuildNumber}" : "${appVersionMajorMinor}-local" + +// AGP 9 removed setProperty("archivesBaseName") from defaultConfig. +// The replacement is the project-level base extension. +base { + archivesName = "SENDA-${appVersionName}" +} android { - compileSdk 33 + compileSdk 35 namespace "de.uol.neuropsy.senda" - ndkVersion "25.2.9519653" + // NDK r28+ compiles 16 KB ELF-aligned by default (Google Play requirement as of Nov 2025). + // The CMakeLists.txt also carries explicit linker flags as a belt-and-suspenders measure. + ndkVersion "28.0.12433566" defaultConfig { vectorDrawables.useSupportLibrary = true applicationId "de.uol.neuropsy.senda" minSdkVersion 30 - targetSdkVersion 31 - versionCode 109 - versionName "1.3" - setProperty("archivesBaseName", "SENDA-$versionName") + targetSdkVersion 35 + versionCode appVersionCode + versionName appVersionName } + signingConfigs { + release { + def keystorePath = System.getenv("KEYSTORE_PATH") ?: "keystore.jks" + def keystoreFile = file(keystorePath) + if (keystoreFile.exists()) { + storeFile keystoreFile + storePassword System.getenv("KEYSTORE_PASSWORD") + keyAlias System.getenv("KEY_ALIAS") + keyPassword System.getenv("KEY_PASSWORD") + } + } + } + + 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' } } + + // AGP 9.0.1+ automatically 16 KB-zipaligns uncompressed .so files. + // useLegacyPackaging is kept so that the Movella and MediaPipe prebuilt libs + // (whose ELF segments are still 4 KB-aligned) are compressed into the APK and + // extracted at install time — the only way they can load on 16 KB devices until + // those SDKs ship 16 KB-aligned binaries. + packagingOptions { + jniLibs { + useLegacyPackaging true + } + } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -42,6 +84,11 @@ android { kotlinOptions { jvmTarget = '1.8' } + testOptions { + unitTests { + includeAndroidResources = true + } + } } // import DownloadModels task @@ -62,10 +109,33 @@ dependencies { implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - // Mediapipe Library - implementation 'com.google.mediapipe:tasks-audio:0.20230731' - + // Mediapipe Library — 0.10.14+ ships 16 KB-aligned native libs (old 0.20230731 did not) + implementation 'com.google.mediapipe:tasks-audio:0.10.14' + implementation "androidx.activity:activity-ktx:1.9.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01" + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + + + // unit‐test deps + testImplementation "junit:junit:4.13.2" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0" + testImplementation "app.cash.turbine:turbine:0.12.1" + testImplementation "io.mockk:mockk:1.13.5" + testImplementation "androidx.test:core:1.5.0" + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'// for ApplicationProvider + + // instrumented‐test deps + androidTestImplementation "androidx.test.ext:junit:1.1.5" + androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" + // coroutine test +// turbine for Flow testing + testImplementation "app.cash.turbine:turbine:0.12.1" +// MockK for mocking + testImplementation "io.mockk:mockk:1.13.5" +// AndroidX test for ApplicationProvider + testImplementation "androidx.test:core:1.5.0" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f214972b..1f1f0151 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + @@ -14,6 +13,13 @@ + + + + @@ -28,19 +34,28 @@ android:theme="@style/AppTheme"> + android:theme="@style/AppTheme"> + + + + + android:name=".service.LSLService" + android:foregroundServiceType="connectedDevice|microphone|location" /> diff --git a/app/src/main/java/de/uol/neuropsy/senda/AudioBridge.java b/app/src/main/java/de/uol/neuropsy/senda/AudioBridge.java deleted file mode 100644 index f6072ae4..00000000 --- a/app/src/main/java/de/uol/neuropsy/senda/AudioBridge.java +++ /dev/null @@ -1,104 +0,0 @@ -package de.uol.neuropsy.senda; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.media.AudioFormat; -import android.media.AudioRecord; -import android.media.MediaRecorder; -import android.os.Build; -import android.util.Log; - -import java.io.IOException; - -import edu.ucsd.sccn.LSL; - -public class AudioBridge { - static String TAG = AudioBridge.class.getSimpleName(); - //LSL Outlets - Boolean checkFlag = false; - Thread mAudioThread; - static LSL.StreamOutlet audioOutlet = null; - - //LSL Streams - private LSL.StreamInfo audio = null; - - // sensor sampling options - private static final int AUDIO_RECORDING_RATE = 44100; - - // the pull-values thread sleeps for this amount of ms in every iteration before pulling new sensor values from MainActivity and pushing them - private static final int THREAD_INTERVAL = 8; - // the sampling rate of every stream depends on the thread sleep interval, not the OS - private static final int SAMPLING_RATE = 1000 / THREAD_INTERVAL; // how many values do we receive per ms - - // audio settings - private static final int CHANNEL = AudioFormat.CHANNEL_IN_STEREO; - private final int audio_channel_count = 2; - private static final int FORMAT = AudioFormat.ENCODING_PCM_FLOAT; - private AudioRecord recorder = null; - - /** - * Factor by that the minimum buffer size is multiplied. The bigger the factor is the less - * likely it is that samples will be dropped, but more memory will be used. The minimum buffer - * size is determined by {@link AudioRecord#getMinBufferSize(int, int, int)} and depends on the - * recording settings. - */ - private static final int BUFFER_SIZE_FACTOR = 2; - - /** - * Size of the buffer where the audio data is stored by Android - */ - private static final int BUFFER_SIZE = AudioRecord.getMinBufferSize(AUDIO_RECORDING_RATE, CHANNEL, FORMAT) * BUFFER_SIZE_FACTOR; - float[] audio_buffer = new float[BUFFER_SIZE]; - - public AudioBridge(Context context) { - mAudioThread = new Thread(new Runnable() { - @Override - public void run() { - audio = new LSL.StreamInfo("Audio " + Build.MODEL, - "audio", audio_channel_count, AUDIO_RECORDING_RATE, LSL.ChannelFormat.float32, Build.FINGERPRINT); - try { - audioOutlet = new LSL.StreamOutlet(audio); - recorder = new AudioRecord(MediaRecorder.AudioSource.MIC, AUDIO_RECORDING_RATE, CHANNEL, FORMAT, BUFFER_SIZE); - } catch (IOException e) { - e.printStackTrace(); - } - while (!checkFlag) { - recorder.startRecording(); - recorder.read(audio_buffer, 0, audio_buffer.length, AudioRecord.READ_BLOCKING); - audioOutlet.push_chunk(audio_buffer); - } - } - }); - mAudioThread.start(); - } - - public void Start() { - } - - public void Stop() { - Log.e(TAG, "Stopping audio bridge"); - checkFlag = true; - try { - mAudioThread.join(); - } catch (InterruptedException e) { - - } - audioOutlet.close(); - audio.destroy(); - audio = null; - - if (null != recorder) { - try { - recorder.stop(); - recorder.release(); - } catch (RuntimeException ex) { - Log.e("AudioBridge","Error while stopping audio recording: "+ex.toString()); - recorder.release(); - } - } - } - - @Override - protected void finalize() { - } -} diff --git a/app/src/main/java/de/uol/neuropsy/senda/LSLService.java b/app/src/main/java/de/uol/neuropsy/senda/LSLService.java deleted file mode 100644 index 0b647cf8..00000000 --- a/app/src/main/java/de/uol/neuropsy/senda/LSLService.java +++ /dev/null @@ -1,240 +0,0 @@ -package de.uol.neuropsy.senda; - -import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA; -import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION; -import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; -import static de.uol.neuropsy.senda.MainActivity.streamingNow; -import static de.uol.neuropsy.senda.MainActivity.streamingNowBtn; - -import android.annotation.SuppressLint; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.graphics.Color; -import android.hardware.Sensor; -import android.hardware.SensorManager; -import android.net.wifi.WifiManager; -import android.os.Build; -import android.os.IBinder; -import android.os.PowerManager; -import android.util.Log; -import android.view.View; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.LinearInterpolator; -import android.widget.Toast; - -import androidx.annotation.RequiresApi; -import androidx.core.app.NotificationCompat; - -import com.google.mediapipe.tasks.audio.core.RunningMode; - -import java.util.Vector; - - -/** - * Created by aliayubkhan on 19/04/2018. - */ - -public class LSLService extends Service { - - private static final String TAG = LSLService.class.getSimpleName(); - - private final Vector sensorBridges = new Vector<>(); - - private LocationBridge mLocationBridge = null; - - private AudioBridge mAudioBridge = null; - - private AudioClassifierHelper mAudioClassifier = null; - - public LSLService() { - super(); - } - - String uniqueID = Build.FINGERPRINT; - String deviceName = Build.MODEL; - - //Wake Lock - PowerManager.WakeLock wakelock; - - WifiManager.MulticastLock multicastLock; - - //Animation for Streaming - Animation animation = new AlphaAnimation((float) 0.5, 0); - - @SuppressLint("WakelockTimeout") - @Override - public void onCreate() { - PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); - assert pm != null; - wakelock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, getClass().getCanonicalName()); - wakelock.acquire(); - WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); - if (wifiManager != null) { - multicastLock = wifiManager.createMulticastLock("Log_Tag"); - multicastLock.acquire(); - } - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - - // this method is part of the mechanisms that allow this to be a foreground channel - createNotificationChannel(); - - if (streamingNow == null) { - throw new AssertionError("StreamingNow is Null"); - } - streamingNow.setVisibility(View.VISIBLE); - streamingNowBtn.setVisibility(View.INVISIBLE); - - animation.setDuration(850); - animation.setInterpolator(new LinearInterpolator()); // do not alter - // animation rate - animation.setRepeatCount(Animation.INFINITE); // Repeat animation - // infinitely - animation.setRepeatMode(Animation.REVERSE); // Reverse animation at the - // end so the button will fade back in - // streamingNowBtn.startAnimation(animation); - streamingNow.startAnimation(animation); - - Log.i(TAG, "Service onStartCommand"); - Toast.makeText(this, "Starting LSL!", Toast.LENGTH_SHORT).show(); - - //Setting All sensors - SensorManager msensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); - assert msensorManager != null; - if (intent.getBooleanExtra("Accelerometer", false)) - sensorBridges.add(new SensorBridge(3, msensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER))); - if (intent.getBooleanExtra("Light", false)) - sensorBridges.add(new SensorBridge(1, msensorManager.getDefaultSensor(Sensor.TYPE_LIGHT))); - if (intent.getBooleanExtra("Proximity", false)) - sensorBridges.add(new SensorBridge(1, msensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY))); - if (intent.getBooleanExtra("Gravity", false)) - sensorBridges.add(new SensorBridge(3, msensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY))); - if (intent.getBooleanExtra("Linear Acceleration", false)) - sensorBridges.add(new SensorBridge(3, msensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION))); - if (intent.getBooleanExtra("Rotation Vector", false)) - sensorBridges.add(new SensorBridge(5, msensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR))); - if (intent.getBooleanExtra("Gyroscope", false)) - sensorBridges.add(new SensorBridge(3, msensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE))); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - if (intent.getBooleanExtra("Step Count", false)) - sensorBridges.add(new SensorBridge(1, msensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER))); - } - - for (SensorBridge sensorBridge : sensorBridges) { - msensorManager.registerListener(sensorBridge, sensorBridge.mSensor, SensorManager.SENSOR_DELAY_UI); - sensorBridge.Start(); - } - - if (intent.getBooleanExtra("Location", false)) { - mLocationBridge = new LocationBridge(this); - mLocationBridge.Start(); - } - - if (intent.getBooleanExtra("Audio", false)) { - mAudioBridge = new AudioBridge(this); - mAudioBridge.Start(); - } - - if (intent.getBooleanExtra("Audio classifier", false)) { - mAudioClassifier = new AudioClassifierHelper(this, AudioClassifierHelper.DISPLAY_THRESHOLD, AudioClassifierHelper.DEFAULT_OVERLAP, AudioClassifierHelper.DEFAULT_NUM_OF_RESULTS, RunningMode.AUDIO_STREAM, null); - } - - MainActivity.isRunning = true; - - // This service is killed by the OS if it is not started as background service - // This feature is only supported in Android 10 or higher - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startMyOwnForeground(); - Toast.makeText(this, "SENDA can safely run in background!", Toast.LENGTH_LONG).show(); - } else { - startForeground(1, new Notification()); - Toast.makeText(this, "SENDA might be killed when in background!", Toast.LENGTH_LONG).show(); - } - return START_NOT_STICKY; - } - - // From https://stackoverflow.com/questions/47531742/startforeground-fail-after-upgrade-to-android-8-1 - // and https://androidwave.com/foreground-service-android-example/ - @RequiresApi(api = Build.VERSION_CODES.O) - private void startMyOwnForeground() { - String NOTIFICATION_CHANNEL_ID = "de.uol.neuropsy.senda"; - String channelName = "SENDA Background Service"; - NotificationChannel chan = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT); - chan.setLightColor(Color.GREEN); - chan.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); - NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - assert manager != null; - manager.createNotificationChannel(chan); - - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID); - Notification notification = notificationBuilder.setOngoing(true) - .setSmallIcon(R.mipmap.ic_launcher_round) - .setContentTitle("SENDA is running in background!") - .setPriority(NotificationManager.IMPORTANCE_DEFAULT) - .setCategory(Notification.CATEGORY_SERVICE) - .build(); - int information_id = 35; // this must be unique and not 0, otherwise it does not have a meaning - startForeground(information_id, notification, FOREGROUND_SERVICE_TYPE_LOCATION | FOREGROUND_SERVICE_TYPE_CAMERA | FOREGROUND_SERVICE_TYPE_MICROPHONE); - } - - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel serviceChannel = new NotificationChannel( - "FOREGROUNDCHANNELSENDA", - "Foreground Service Channel SENDA", - NotificationManager.IMPORTANCE_DEFAULT - ); - NotificationManager manager = getSystemService(NotificationManager.class); - manager.createNotificationChannel(serviceChannel); - } - - - } - - @Override - public IBinder onBind(Intent arg0) { - Log.i(TAG, "Service onBind"); - return null; - } - - @Override - public void onDestroy() { - - MainActivity.isRunning = false; - - Log.i(TAG, "Service onDestroy"); - Toast.makeText(this, "Closing LSL!", Toast.LENGTH_SHORT).show(); - - streamingNow.setVisibility(View.INVISIBLE); - streamingNowBtn.setVisibility(View.INVISIBLE); - streamingNowBtn.clearAnimation(); - streamingNow.clearAnimation(); - wakelock.release(); - multicastLock.release(); - //Unregister all sensor listeners - SensorManager msensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); - assert msensorManager != null; - - for (SensorBridge sensorBridge : sensorBridges) { - msensorManager.unregisterListener(sensorBridge); - sensorBridge.Stop(); - } - - if (mLocationBridge != null) { - mLocationBridge.Stop(); - } - - if (mAudioBridge != null) { - mAudioBridge.Stop(); - } - if (mAudioClassifier != null) - mAudioClassifier.stopAudioClassification(); - } -} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/LocationBridge.java b/app/src/main/java/de/uol/neuropsy/senda/LocationBridge.java deleted file mode 100644 index 645cf39f..00000000 --- a/app/src/main/java/de/uol/neuropsy/senda/LocationBridge.java +++ /dev/null @@ -1,73 +0,0 @@ -package de.uol.neuropsy.senda; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.location.Location; -import android.os.Build; -import android.util.Log; - -import com.google.android.gms.location.FusedLocationProviderClient; -import com.google.android.gms.location.LocationCallback; -import com.google.android.gms.location.LocationListener; -import com.google.android.gms.location.LocationRequest; -import com.google.android.gms.location.LocationResult; -import com.google.android.gms.location.LocationServices; -import com.google.android.gms.location.Priority; - -import java.io.IOException; -import java.util.Random; - -import edu.ucsd.sccn.LSL; - -public class LocationBridge { - - static String TAG = LocationBridge.class.getSimpleName(); - - // GoogleApiClient instance to connect to Google Play Services - private final FusedLocationProviderClient mlocationProviderClient; - private final LocationRequest mlocationRequest; - private final LocationCallback mlocationCallback; - - private LSL.StreamOutlet mStreamOutlet; - - LocationBridge(Context context) { - LSL.StreamInfo mStreamInfo = new LSL.StreamInfo("Location" + " " + Build.MODEL, - "other", 4, LSL.IRREGULAR_RATE, LSL.ChannelFormat.float32, Build.FINGERPRINT); - try { - mStreamOutlet = new LSL.StreamOutlet(mStreamInfo); - } catch (IOException e) { - Log.e("LocationBridge", e.toString()); - e.printStackTrace(); - } - if(context==null) - Log.e("LocationBridge","Context is null!"); - mlocationProviderClient = LocationServices.getFusedLocationProviderClient(context); - mlocationRequest = new LocationRequest.Builder(1000).setPriority(Priority.PRIORITY_HIGH_ACCURACY).build(); - mlocationCallback = new LocationCallback() { - @Override - public void onLocationResult(LocationResult locationResult) { - // Handle the received location updates - if (locationResult != null) { - Location location = locationResult.getLastLocation(); - if (location != null) { - double[] loc = {location.getLatitude(), location.getLongitude(), location.getAltitude(),location.getAccuracy()}; - mStreamOutlet.push_sample(loc); - } - } - } - }; - } - @SuppressLint("MissingPermissions") - void Start() { - mlocationProviderClient.requestLocationUpdates(mlocationRequest, mlocationCallback, null); - } - - void Stop() { - mlocationProviderClient.removeLocationUpdates(mlocationCallback); - mStreamOutlet.close(); - } - - @Override - protected void finalize() { - } -} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/MainActivity.java b/app/src/main/java/de/uol/neuropsy/senda/MainActivity.java deleted file mode 100644 index 8e0b393d..00000000 --- a/app/src/main/java/de/uol/neuropsy/senda/MainActivity.java +++ /dev/null @@ -1,604 +0,0 @@ -package de.uol.neuropsy.senda; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothManager; -import android.bluetooth.le.ScanSettings; -import android.content.ComponentName; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.hardware.Sensor; -import android.hardware.SensorManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.provider.Settings; -import android.util.Log; -import android.util.SparseBooleanArray; -import android.view.View; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.LinearInterpolator; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.CompoundButton; -import android.widget.ImageView; -import android.widget.ListView; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.appcompat.widget.AppCompatCheckBox; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.xsens.dot.android.sdk.interfaces.DotScannerCallback; -import com.xsens.dot.android.sdk.interfaces.DotSyncCallback; -import com.xsens.dot.android.sdk.models.DotDevice; -import com.xsens.dot.android.sdk.models.DotSyncManager; -import com.xsens.dot.android.sdk.utils.DotScanner; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.stream.Collectors; - -import com.xsens.dot.android.sdk.DotSdk; - -public class MainActivity extends Activity implements DotScannerCallback, DotSyncCallback { - - private Boolean isScanning = false; - private DotScanner mXsScanner; - public HashMap mConnectedDevices = new HashMap<>(); - public HashMap mActiveDevices = new HashMap<>(); - static String TAG = MainActivity.class.getSimpleName(); - - @SuppressLint("StaticFieldLeak") - static TextView tv; - - static boolean isRunning = false; - - List SensorName = new ArrayList<>(); - ArrayAdapter adapter; - ListView lv; - public static ArrayList selectedItems = new ArrayList<>(); - - //Streaming Identification - @SuppressLint("StaticFieldLeak") - static ImageView streamingNowBtn; - @SuppressLint("StaticFieldLeak") - static TextView streamingNow; - - ProgressBar progressBar; - - int backButtonCount = 0; - - //Settings button - ImageView settings_button; - - //Requesting run-time permissions - //Create placeholder for user's consent to record_audio and access location permissions. - //This will be used in handling callback - private final int PERMISSIONS_REQUEST_CODE = 1; - - // - private final int START_SCAN_REQUEST_CODE = 2000; - - public static List POWERMANAGER_INTENTS = Arrays.asList(new Intent().setComponent(new ComponentName("com.miui.securitycenter", "com.miui.permcenter.autostart.AutoStartManagementActivity")), new Intent().setComponent(new ComponentName("com.letv.android.letvsafe", "com.letv.android.letvsafe.AutobootManageActivity")), new Intent().setComponent(new ComponentName("com.huawei.systemmanager", "com.huawei.systemmanager.optimize.process.ProtectActivity")), new Intent().setComponent(new ComponentName("com.coloros.safecenter", "com.coloros.safecenter.permission.startup.StartupAppListActivity")), new Intent().setComponent(new ComponentName("com.coloros.safecenter", "com.coloros.safecenter.startupapp.StartupAppListActivity")), new Intent().setComponent(new ComponentName("com.oppo.safe", "com.oppo.safe.permission.startup.StartupAppListActivity")), new Intent().setComponent(new ComponentName("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity")), new Intent().setComponent(new ComponentName("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.BgStartUpManager")), new Intent().setComponent(new ComponentName("com.vivo.permissionmanager", "com.vivo.permissionmanager.activity.BgStartUpManagerActivity")), new Intent().setComponent(new ComponentName("com.asus.mobilemanager", "com.asus.mobilemanager.entry.FunctionActivity")).setData(android.net.Uri.parse("mobilemanager://function/entry/AutoStart"))); - - private Intent LSLIntent = null; - - // Override the necessary lifecycle methods - @Override - protected void onStart() { - super.onStart(); - Log.e("Location", "onStart called"); - } - - @Override - protected void onStop() { - super.onStop(); - Log.e(TAG, "MainActivity::OnStop()"); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - Log.e(TAG, "MainActivity::OnDestroy()"); - for (MovellaBridge device : mActiveDevices.values()) - device.Stop(); - } - - public MainActivity() { - } - - /** - * Setup for Xsens DOT SDK. - */ - private void initDotSdk() { - // Get the version name of SDK. - String version = DotSdk.getSdkVersion(); - Log.i(TAG, "initDotSdk() - version: $version"); - // Enable this feature to monitor logs from SDK. - DotSdk.setDebugEnabled(false); - // Enable this feature then SDK will start reconnection when the connection is lost. - DotSdk.setReconnectEnabled(true); - } - - - /** - * Called when the activity is first created. - */ - @SuppressLint("SetTextI18n") - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - initDotSdk(); - setContentView(R.layout.activity_main); - tv = (TextView) findViewById(R.id.tv); - - bindButtons(); - - streamingNow = (TextView) findViewById(R.id.streamingNow); - streamingNowBtn = (ImageView) findViewById(R.id.streamingNowBtn); - - progressBar = (ProgressBar) findViewById(R.id.progressBar); - startPowerSaverIntent(this); - - tv.setText("Available Streams: "); - lv = (ListView) findViewById(R.id.sensors); - lv.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - adapter = new ArrayAdapter<>(getApplicationContext(), R.layout.list_view_text, R.id.streamsSelected, SensorName); - lv.setAdapter(adapter); - - SwipeRefreshLayout mSwipeRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swiperefresh); - - mSwipeRefreshLayout.setOnRefreshListener(() -> { - checkAvailableSensors(); - StartScan(); - }); - mXsScanner = new DotScanner(this, this); - mXsScanner.setScanMode(ScanSettings.SCAN_MODE_BALANCED); - - checkAvailableSensors(); - - lv.setOnItemClickListener(new AdapterView.OnItemClickListener() { - public void onItemClick(AdapterView parent, View view, int position, long id) { - // selected item - String selectedItem = ((TextView) view).getText().toString(); - if (selectedItem.contains("Audio") && lv.isItemChecked(position)) { - if (!checkAudioPermission()) { - lv.setItemChecked(position, false); - requestAudioPermissions(1000 + position); - } - } - if (selectedItem.contains("Location") && lv.isItemChecked(position)) { - if (!checkLocationPermission()) { - requestLocationPermissions(1000 + position); - lv.setItemChecked(position, false); - } - } - } - }); - } // end onCreate - - public Boolean isActivated(String s) { - for (String item : selectedItems) { - if (item.equals(s)) return true; - } - return false; - } - - private void myStartForegroundService(Intent intent) { - intent.putExtra("inputExtra", "SENDA Foreground Service in Android"); - ContextCompat.startForegroundService(this, intent); - } - - // Check if the permissions are already granted - private boolean checkLocationPermission() { - boolean hasFineLocationPermission = (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED); - boolean hasBackgroundLocationPermission = (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED); - return hasFineLocationPermission && hasBackgroundLocationPermission; - } - - private boolean checkBackgroundLocationPermission() { - return (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED); - } - - private boolean checkAudioPermission() { - return (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED); - } - - private boolean checkBluetoothPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) - return (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED); - else return true; - } - - private void requestAudioPermissions(int requestCode) { - String[] permissions = new String[]{Manifest.permission.RECORD_AUDIO}; - ActivityCompat.requestPermissions(this, permissions, requestCode); - } - - private void requestBluetoothPermissions(int requestCode) { - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - String[] permissions = new String[]{Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT}; - ActivityCompat.requestPermissions(this, permissions, requestCode); - } - } - - private boolean checkAndRequestBluetoothEnabled() { - BluetoothManager bluetoothManager = getSystemService(BluetoothManager.class); - BluetoothAdapter bluetoothAdapter = bluetoothManager.getAdapter(); - if (bluetoothAdapter == null) { - Toast.makeText(this, "This device does not support Bluetooth", Toast.LENGTH_SHORT).show(); - return false; - } - if (!bluetoothAdapter.isEnabled()) { - Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); - startActivityForResult(enableBtIntent, 1002); - return false; - } - return true; - } - - private void requestLocationPermissions(int requestCode) { - String[] permissions; - if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) - permissions = new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}; - else { - permissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; - } - ActivityCompat.requestPermissions(this, permissions, requestCode); - } - - private void requestBackgroundLocationPermission(int requestCode) { - String[] permissions = new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}; - ActivityCompat.requestPermissions(this, permissions, requestCode); - } - - @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - for (int ii = 0; ii < permissions.length; ii++) { - if (grantResults[ii] == PackageManager.PERMISSION_GRANTED) { - // FINE_LOCATION needs special treatment b/c we need to request BACKGROUND_LOCATION after it - if (permissions[ii].equals(Manifest.permission.ACCESS_FINE_LOCATION)) { - // Background location has to be requested after fine location is granted. - if (!checkBackgroundLocationPermission()) { - requestBackgroundLocationPermission(requestCode); - } - } - // All other cases (including background location) set the list item checked if we came from the OnClickListener - else if (requestCode >= 1000 && requestCode < 2000) { - lv.setItemChecked(requestCode - 1000, true); - } - // We came from StartScan(), commence scan - else if (requestCode >= 2000) { - Log.e(TAG, "Coming from StartScan, commencing scan"); - StartScan(); - } - } else { - // Denied permission and should not show rationale -> Permission request is invisible to user, show error message - if (!shouldShowRequestPermissionRationale(permissions[ii])) { - //TODO Map permissions string to human readable permission - try { - Toast.makeText(this, "Missing permission: " + this.getPackageManager().getPermissionInfo(permissions[ii], 0).loadLabel(this.getPackageManager()), Toast.LENGTH_SHORT).show(); - } catch (Exception e) { - Toast.makeText(this, "Missing a permission and encountered an error trying to find out which.", Toast.LENGTH_SHORT).show(); - Log.e(TAG, "Missing a permission and encountered an error trying to find out which:" + e.toString()); - } - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - Uri uri = Uri.fromParts("package", getPackageName(), null); - intent.setData(uri); - startActivity(intent); - } - } - } - } - - @Override - public void onBackPressed() { - if (backButtonCount >= 1) { - if (isRunning) { - for (MovellaBridge device : mActiveDevices.values()) { - device.Stop(); - } - stopService(LSLIntent); - } - for (MovellaBridge device : mConnectedDevices.values()) { - SensorName.remove(device.getDisplayName()); - device.getDevice().disconnect(); - mConnectedDevices.remove(device); - adapter.notifyDataSetChanged(); - } - this.finishAffinity(); - backButtonCount = 0; - } else { - Toast.makeText(this, "Press the back button once again to close the application.", Toast.LENGTH_SHORT).show(); - backButtonCount++; - } - } - - public static void startPowerSaverIntent(final Context context) { - SharedPreferences settings = context.getSharedPreferences("ProtectedApps", Context.MODE_PRIVATE); - boolean skipMessage = settings.getBoolean("skipProtectedAppCheck", false); - if (!skipMessage) { - final SharedPreferences.Editor editor = settings.edit(); - boolean foundCorrectIntent = false; - for (final Intent intent : POWERMANAGER_INTENTS) { - if (isCallable(context, intent)) { - foundCorrectIntent = true; - final AppCompatCheckBox dontShowAgain = new AppCompatCheckBox(context); - dontShowAgain.setText(R.string.dont_show_again); - dontShowAgain.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - editor.putBoolean("skipProtectedAppCheck", isChecked); - editor.apply(); - } - }); - - new AlertDialog.Builder(context).setTitle(Build.MANUFACTURER + " Protected Apps").setMessage(String.format("%s requires to be enabled in 'Protected Apps' to function properly.%n", context.getString(R.string.app_name))).setView(dontShowAgain).setPositiveButton("Go to settings", new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - context.startActivity(intent); - } - }).setNegativeButton(android.R.string.cancel, null).show(); - break; - } - } - if (!foundCorrectIntent) { - editor.putBoolean("skipProtectedAppCheck", true); - editor.apply(); - } - } - } - - private static boolean isCallable(Context context, Intent intent) { - List list = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - return list.size() > 0; - } - - @Override - public void onDotScanned(BluetoothDevice bluetoothDevice, int i) { - new MovellaBridge(this, bluetoothDevice, this); - Log.e(TAG, "Initializing " + bluetoothDevice.getAddress()); - } - - void StartScan() { - // TODO trigger scan in permission result callback when we come from here - if (!checkBluetoothPermission()) { - Log.i(TAG, "Do not have Bluetooth permission, asking for it"); - requestBluetoothPermissions(START_SCAN_REQUEST_CODE); - ((SwipeRefreshLayout) findViewById(R.id.swiperefresh)).setRefreshing(false); - return; - } else if (!checkAndRequestBluetoothEnabled()) return; - Log.e(TAG, "Starting scan"); - for (MovellaBridge device : mConnectedDevices.values()) { - // Do not disconnect currently active devices - if (device.getDevice() != null && isRunning && !mActiveDevices.containsKey(device.getDevice().getAddress())) { - SensorName.remove(device.getDisplayName()); - device.getDevice().disconnect(); - } - } - adapter.notifyDataSetChanged(); - mConnectedDevices.clear(); - ((SwipeRefreshLayout) findViewById(R.id.swiperefresh)).setRefreshing(true); - mXsScanner.startScan(); - isScanning = true; - - final Handler handler = new Handler(Looper.getMainLooper()); - handler.postDelayed(new Runnable() { - @Override - public void run() { - StopScan(); - } - }, 5000); - } - - - void StopScan() { - Log.e(TAG, "Stopping scan"); - mXsScanner.stopScan(); - isScanning = false; - ((SwipeRefreshLayout) findViewById(R.id.swiperefresh)).setRefreshing(false); - } - - public void onInitDone(MovellaBridge device) { - mConnectedDevices.put(device.getDevice().getAddress(), device); - if (!SensorName.contains(device.getDisplayName())) { - SensorName.add(device.getDisplayName()); - adapter.notifyDataSetChanged(); - } - } - - void bindButtons() { - LSLIntent = new Intent(this, LSLService.class); - Button start = (Button) findViewById(R.id.startLSL); - start.setOnClickListener(v -> { - onStartButtonPressedPreSync(); - syncMovellaSensors(); - // onStartButtonPressedPostSync(); Called by onSyncDone callback - }); - - Button stop = (Button) findViewById(R.id.stopLSL); - stop.setOnLongClickListener(v -> { - if (backButtonCount < 2) { - backButtonCount++; - return true; - } - - return true; - }); - - stop.setOnClickListener(v -> { - if (isRunning) { - DotSyncManager.getInstance(this).stopSyncing(); - for (MovellaBridge device : mActiveDevices.values()) { - device.Stop(); - } - stopService(LSLIntent); - lv.setEnabled(true); - lv.setAlpha(1f); - } - }); - } - - void syncMovellaSensors() { - if (mActiveDevices.size() < 2) { - Log.e("syncMovellaSensors", "No syncing needed"); - //No syncing needed, proceed to PostSync - onStartButtonPressedPostSync(); - return; - } - Log.e("MainActivity", "Try syncing"); - streamingNow.setVisibility(View.VISIBLE); - streamingNow.setText("Syncing Movella sensors..."); - - - //Animation for Streaming - Animation animation = new AlphaAnimation((float) 0.5, 0); - animation.setDuration(850); - animation.setInterpolator(new LinearInterpolator()); // do not alter - // animation rate - animation.setRepeatCount(Animation.INFINITE); // Repeat animation - // infinitely - animation.setRepeatMode(Animation.REVERSE); // Reverse animation at the - // end so the button will fade back in - // streamingNowBtn.startAnimation(animation); - streamingNow.startAnimation(animation); - - ArrayList activeDeviceList = mActiveDevices.values().stream().map(MovellaBridge::getDevice).collect(Collectors.toCollection(ArrayList::new)); - activeDeviceList.get(0).setRootDevice(true); - activeDeviceList.forEach(v -> Log.e("syncMovellaSensors", "I must sync: " + v.getTag())); - DotSyncManager.getInstance(this).startSyncing(activeDeviceList, 1); - } - - void checkAvailableSensors() { - SensorName.clear(); - SensorManager sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); - //Not available in Java 7: sensor.stream().anyMatch(s -> s.getType() == Sensor.TYPE_ACCELEROMETER)) - if (sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) - SensorName.add("Accelerometer"); - if (sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT) != null) SensorName.add("Light"); - if (sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY) != null) - SensorName.add("Proximity"); - if (sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) != null) SensorName.add("Gravity"); - if (sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION) != null) - SensorName.add("Linear Acceleration"); - if (sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null) - SensorName.add("Rotation Vector"); - if (sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null) - SensorName.add("Step Count"); - if (sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) - SensorName.add("Gyroscope"); - // Do not need to check: Asking for audio permission if user selects this item - SensorName.add("Audio"); - // Do not need to check: Asking for audio permission if user selects this item - SensorName.add("Audio classifier"); - // Do not need to check: Asking for location permission if user selects this item - SensorName.add("Location"); - adapter.notifyDataSetChanged(); - } - - void onStartButtonPressedPreSync() { - Log.e("MainActivity", "OnStartButtonPressedPreSync " + android.os.Process.myTid()); - if (!isRunning) { - lv.setEnabled(false); - lv.setAlpha(0.1f); - // Build the list of selected items and give it over to the LSLIntent - SparseBooleanArray checked = lv.getCheckedItemPositions(); - for (int i = 0; i < lv.getAdapter().getCount(); i++) { - Log.e(TAG, lv.getItemAtPosition(i).toString() + " " + checked.get(i)); - LSLIntent.putExtra(lv.getItemAtPosition(i).toString(), checked.get(i)); - } - for (MovellaBridge device : mConnectedDevices.values()) { - if (LSLIntent.getBooleanExtra(device.getDisplayName(), false)) { - mActiveDevices.put(device.getDevice().getAddress(), device); - if (device.getDevice().isSynced()) { - Log.e("MainActivity", device.getDisplayName() + " already synced, stopping sync..."); - DotSyncManager.getInstance(this).stopSyncing(); - } - Log.e(TAG, "Adding movella device to list of active devices:" + device.getDisplayName()); - } - } - } - } - - void onStartButtonPressedPostSync() { - Log.e("MainActivity", "OnStartButtonPressedPostSync " + android.os.Process.myTid()); - runOnUiThread(new Thread(() -> { - streamingNow.setVisibility(View.INVISIBLE); - streamingNow.setText("Streaming Data..."); - progressBar.setVisibility(View.GONE); - } - )); - for (MovellaBridge device : mActiveDevices.values()) { - Log.e(TAG, "Starting movella device " + device.getDisplayName()); - device.Start(); - } - // make this a foreground service so that android does not kill it while it is in the background - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - myStartForegroundService(LSLIntent); - - } else { // try our best with older Androids - startService(LSLIntent); - } - - } - - @Override - public void onSyncingStarted(String s, boolean b, int i) { - progressBar.setVisibility(View.VISIBLE); // Make ProgressBar visible - progressBar.setProgress(0); - Log.e("MainActivity", "onSyncingStarted " + b + " " + i); - } - - @Override - public void onSyncingProgress(int i, int i1) { - progressBar.setProgress(i); - Log.e("MainActivity", "onSyncingProgress " + i + " " + i1); - } - - @Override - public void onSyncingResult(String s, boolean b, int i) { - Log.e("MainActivity", "onSyncingResult " + s + " " + b + " " + i); - } - - @Override - public void onSyncingDone(HashMap hashMap, boolean b, int i) { - Log.e("MainActivity", "onSyncingDone " + b + " " + i); - if (b) onStartButtonPressedPostSync(); - else { - runOnUiThread(new Thread(() -> { - lv.setEnabled(true); - lv.setAlpha(1.0f); - Log.e("MainActivity", "SYNC FAILED"); - Toast.makeText(this, "Syncing Failed!", Toast.LENGTH_LONG); - streamingNow.setText("Syncing Failed!"); - progressBar.setProgress(0); - progressBar.setVisibility(View.GONE); - mActiveDevices.clear(); - })); - } - } - - @Override - public void onSyncingStopped(String s, boolean b, int i) { - Log.e("MainActivity", "onSyncingStopped"); - } -} - diff --git a/app/src/main/java/de/uol/neuropsy/senda/MovellaBridge.java b/app/src/main/java/de/uol/neuropsy/senda/MovellaBridge.java deleted file mode 100644 index bc96c652..00000000 --- a/app/src/main/java/de/uol/neuropsy/senda/MovellaBridge.java +++ /dev/null @@ -1,189 +0,0 @@ -package de.uol.neuropsy.senda; - -import android.bluetooth.BluetoothDevice; -import android.content.Context; -import android.os.Build; -import android.util.Log; - -import com.xsens.dot.android.sdk.events.DotData; -import com.xsens.dot.android.sdk.interfaces.DotDeviceCallback; -import com.xsens.dot.android.sdk.models.DotDevice; -import com.xsens.dot.android.sdk.models.DotPayload; -import com.xsens.dot.android.sdk.models.FilterProfileInfo; - -import java.io.IOException; -import java.util.ArrayList; - -import edu.ucsd.sccn.LSL; - -public class MovellaBridge implements DotDeviceCallback { - - static String TAG = MovellaBridge.class.getSimpleName(); - - - private LSL.StreamInfo mMarkerStreamInfo; - private LSL.StreamOutlet mMarkerStreamOutlet; - private LSL.StreamInfo mDataStreamInfo; - private LSL.StreamOutlet mDataStreamOutlet; - - public MovellaBridge(Context context, BluetoothDevice btDevice, MainActivity hostActivity) { - mHost = hostActivity; - mContext = context; - mDevice = new DotDevice(mContext, btDevice, this); - mDevice.connect(); - } - - public DotDevice getDevice() { - if (!mDevice.isInitDone()) { - return null; - } - return mDevice; - } - - private MainActivity mHost = null; - private Boolean mIsConnected = false; - private final DotDevice mDevice; - private Context mContext; - - public Boolean isConnected() { - return mIsConnected; - } - - void Start() { - try { - mMarkerStreamOutlet = new LSL.StreamOutlet(mMarkerStreamInfo); - } catch (IOException e) { - Log.e(TAG, e.toString()); - e.printStackTrace(); - } - try { - mDataStreamOutlet = new LSL.StreamOutlet(mDataStreamInfo); - } catch (IOException e) { - Log.e(TAG, e.toString()); - e.printStackTrace(); - } - assert mDataStreamOutlet != null; - mDevice.startMeasuring(); - Log.i(TAG, getDisplayName() + " StartMeasuring"); - } - - void Stop() { - if (mDevice != null) { - Log.e("MovellaBridge", this.getDisplayName() + " " + mDevice.getConnectionState()); - if (mDevice.getConnectionState() == DotDevice.CONN_STATE_CONNECTED) { - Log.e("MovellaBridge", this.getDisplayName() + " " + mDevice.getMeasurementState()); - if (mDevice.getMeasurementState() == DotDevice.MEASUREMENT_STATE_ON) - mDevice.stopMeasuring(); - } - } - Log.e("MovellaBridge", this.getDisplayName() + ": Finished handling device"); - if (mDataStreamOutlet != null) { - Log.e("MovellaBridge", this.getDisplayName() + ": Close data stream"); - mDataStreamOutlet.close(); - mDataStreamOutlet=null; - } - if (mMarkerStreamOutlet != null) { - Log.e("MovellaBridge", this.getDisplayName() + ": Close marker stream"); - mMarkerStreamOutlet.close(); - mMarkerStreamOutlet=null; - } - } - - public String getDisplayName() { - return mDevice.getName() + " " + mDevice.getTag(); - } - - @Override - public void onDotConnectionChanged(String s, int i) { - } - - @Override - public void onDotServicesDiscovered(String s, int i) { - - } - - @Override - public void onDotFirmwareVersionRead(String s, String s1) { - - } - - @Override - public void onDotTagChanged(String s, String s1) { - - } - - @Override - public void onDotBatteryChanged(String s, int i, int i1) { - - } - - @Override - public void onDotDataChanged(String s, DotData dotData) { - float[] data = new float[7]; - - for (int i = 0; i < 3; i++) { - data[i] = dotData.getFreeAcc()[i]; - data[i + 3] = (float) dotData.getEuler()[i]; - } - data[6] = dotData.getSampleTimeFine(); - if (mDataStreamOutlet != null) { - mDataStreamOutlet.push_sample(data); - } else { - Log.e(TAG, getDisplayName() + " mStreamOutlet is Null!"); - } - - } - - @Override - public void onDotInitDone(String s) { - Log.i(TAG, "Movella initialized " + s + " " + mDevice.getTag() + " " + mDevice.getSerialNumber() + "!"); - mDevice.setMeasurementMode(DotPayload.PAYLOAD_TYPE_COMPLETE_EULER); - mHost.onInitDone(this); - mDataStreamInfo = new LSL.StreamInfo(getDisplayName(), "misc", 7, mDevice.getCurrentOutputRate(), LSL.ChannelFormat.float32, Build.FINGERPRINT); - mMarkerStreamInfo = new LSL.StreamInfo(getDisplayName() + " Marker", "Markers", 1, LSL.IRREGULAR_RATE, LSL.ChannelFormat.string, Build.FINGERPRINT); - } - - @Override - public void onDotButtonClicked(String s, long l) { - String[] sample = new String[1]; - sample[0] = mDevice.getTag(); - Log.i(TAG, getDisplayName() + " button pressed!"); - if (mMarkerStreamOutlet != null) - mMarkerStreamOutlet.push_sample(sample); - } - - @Override - protected void finalize() { - } - - - @Override - public void onDotPowerSavingTriggered(String s) { - - } - - @Override - public void onReadRemoteRssi(String s, int i) { - - } - - @Override - public void onDotOutputRateUpdate(String s, int i) { - - } - - @Override - public void onDotFilterProfileUpdate(String s, int i) { - - } - - @Override - public void onDotGetFilterProfileInfo(String s, ArrayList arrayList) { - - } - - @Override - public void onSyncStatusUpdate(String s, boolean b) { - - } -} diff --git a/app/src/main/java/de/uol/neuropsy/senda/SensorBridge.java b/app/src/main/java/de/uol/neuropsy/senda/SensorBridge.java deleted file mode 100644 index 24966ee8..00000000 --- a/app/src/main/java/de/uol/neuropsy/senda/SensorBridge.java +++ /dev/null @@ -1,51 +0,0 @@ -package de.uol.neuropsy.senda; - -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.os.Build; -import android.util.Log; - -import java.io.IOException; - -import edu.ucsd.sccn.LSL; - -import de.uol.neuropsy.senda.utils.Utils; - -public class SensorBridge implements SensorEventListener { - static String TAG=SensorBridge.class.getSimpleName(); - private final LSL.StreamInfo mStreamInfo; - private LSL.StreamOutlet mStreamOutlet; - public Sensor mSensor; - - SensorBridge(int dataSize, Sensor sensor) { - mSensor=sensor; - mStreamInfo = new LSL.StreamInfo(Utils.SimpleSensorType(sensor.getType()) + " " + Build.MODEL, - "eeg", dataSize, LSL.IRREGULAR_RATE, LSL.ChannelFormat.float32, Build.FINGERPRINT); - Log.e(TAG, "Created bridge for "+mStreamInfo.name()); - } - - public void Start() { - try { - mStreamOutlet = new LSL.StreamOutlet(mStreamInfo); - } catch (IOException e) { - Log.e("SensorBridge", e.toString()); - e.printStackTrace(); - } - } - - public void Stop() { - mStreamOutlet.close(); - } - - @Override - public void onSensorChanged(SensorEvent sensorEvent) { - mStreamOutlet.push_chunk(sensorEvent.values); - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) { - } - -} - diff --git a/app/src/main/java/de/uol/neuropsy/senda/data/SensorRepositoryImpl.kt b/app/src/main/java/de/uol/neuropsy/senda/data/SensorRepositoryImpl.kt new file mode 100644 index 00000000..77c179e4 --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/data/SensorRepositoryImpl.kt @@ -0,0 +1,119 @@ +// SensorRepositoryImpl.kt +package de.uol.neuropsy.senda.data + +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.Intent +import android.hardware.Sensor +import android.hardware.SensorManager +import android.util.Log +import androidx.core.content.ContextCompat +import com.xsens.dot.android.sdk.interfaces.DotSyncCallback +import com.xsens.dot.android.sdk.models.DotDevice +import com.xsens.dot.android.sdk.models.DotSyncManager +import com.xsens.dot.android.sdk.utils.DotScanner +import de.uol.neuropsy.senda.domain.SensorRepository +import de.uol.neuropsy.senda.sensor.MovellaMetadata +import de.uol.neuropsy.senda.sensor.SensorConfig +import de.uol.neuropsy.senda.service.LSLService +import de.uol.neuropsy.senda.utils.Utils.SENSOR_NAMES +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ConcurrentHashMap +import javax.crypto.SealedObject + +sealed class SyncStatus { + data class Progress(val progress : Int) : SyncStatus() + class Success : SyncStatus() + class Failed : SyncStatus() + class Stopped: SyncStatus() +} + +/** + * Concrete implementation of SensorRepository using Android sensors, + * Movella DOT SDK, and LSLService for streaming. + */ +class SensorRepositoryImpl(private val context: Context) : SensorRepository { + + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val availableSensors = mutableListOf() + + override fun getAvailableOnboardSensors(): List { + val available = mutableListOf() + SENSOR_NAMES.forEach { (type, name) -> + sensorManager.getDefaultSensor(type) + ?.let { available.add(SensorConfig.Onboard(name = name, type = type)) } + } + available.add(SensorConfig.Audio) + available.add(SensorConfig.AudioClassification) + available.add(SensorConfig.Location) + available.forEach { newSensor->if(availableSensors.none { oldSensor->oldSensor.name==newSensor.name }) availableSensors.add(newSensor) } + Log.e("SensorRepositoryImpl","I have ${available.map { it.name }} and cached ${availableSensors.map { it.name }}") + return available + } + + suspend fun scanForMovellaDevicesOnce( + timeoutMs: Long = 5_000L + ): List = coroutineScope { + val devices = ConcurrentHashMap.newKeySet() + val scanner = DotScanner(context) { bt, _ -> + launch { + val deviceName = MovellaMetadata.getDeviceName(context, bt) + if (devices.none { it.address == bt.address }) { + devices.add(SensorConfig.Movella(bt.address, deviceName)) + } + }}.apply { + setScanMode(ScanSettings.SCAN_MODE_BALANCED) + startScan() + } + + // Wait either until timeout or the scope is canceled + try { + withTimeout(timeoutMs) { + // just suspend until timeout; callbacks keep adding to 'names' + suspendCancellableCoroutine { /* no-op */ } + } + } catch (_: TimeoutCancellationException) { + // expected path: timeout expired + } finally { + scanner.stopScan() + } + devices.toList() + } + + + override fun scanForMovellaDevices(): Flow> = callbackFlow { + val devices = mutableListOf() + val scanner = DotScanner(context) { bt, _ -> + // launch a coroutine to fetch the name + launch { + val deviceName = MovellaMetadata.getDeviceName(context, bt) + if (devices.none { it.address == bt.address }) { + devices.add(SensorConfig.Movella(bt.address,deviceName)) + if(!availableSensors.contains(SensorConfig.Movella(bt.address,deviceName))) + availableSensors.add(SensorConfig.Movella(bt.address,deviceName)) + trySend(devices) + } + } + }.apply { + setScanMode(ScanSettings.SCAN_MODE_BALANCED) + startScan() + delay(5000) + close() + } + awaitClose { scanner.stopScan() } + } + + fun getAvailableSensors() : List{ + return availableSensors + } +} diff --git a/app/src/main/java/de/uol/neuropsy/senda/domain/SensorRepository.kt b/app/src/main/java/de/uol/neuropsy/senda/domain/SensorRepository.kt new file mode 100644 index 00000000..8935ad3b --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/domain/SensorRepository.kt @@ -0,0 +1,22 @@ +// SensorRepository.kt +package de.uol.neuropsy.senda.domain + +import com.xsens.dot.android.sdk.models.DotDevice +import de.uol.neuropsy.senda.data.SyncStatus +import de.uol.neuropsy.senda.sensor.MovellaBridge +import de.uol.neuropsy.senda.sensor.SensorConfig +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface encapsulating all sensor and device operations. + */ +interface SensorRepository { + /** Returns the list of available onboard sensor names. */ + fun getAvailableOnboardSensors(): List + + /** + * Scans for Movella BLE devices and emits the current list of device names. + * Collection is infinite; cancel to stop. + */ + fun scanForMovellaDevices(): Flow> +} diff --git a/app/src/main/java/de/uol/neuropsy/senda/sensor/AudioBridge.kt b/app/src/main/java/de/uol/neuropsy/senda/sensor/AudioBridge.kt new file mode 100644 index 00000000..9f64c9cb --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/sensor/AudioBridge.kt @@ -0,0 +1,110 @@ +package de.uol.neuropsy.senda.sensor + +import android.content.Context +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.os.Build +import android.util.Log +import edu.ucsd.sccn.LSL +import edu.ucsd.sccn.LSL.StreamInfo +import edu.ucsd.sccn.LSL.StreamOutlet +import java.io.IOException +import kotlin.system.exitProcess + +class AudioBridge(context: Context?) : SensorBridge { + //LSL Outlets + var checkFlag = false + var mAudioThread: Thread + + //LSL Streams + private lateinit var audio: StreamInfo + private val audio_channel_count = 2 + private var recorder: AudioRecord? = null + var audio_buffer = FloatArray(BUFFER_SIZE) + + companion object { + var TAG = AudioBridge::class.java.simpleName + var audioOutlet: StreamOutlet? = null + + // sensor sampling options + private const val AUDIO_RECORDING_RATE = 44100 + + // the pull-values thread sleeps for this amount of ms in every iteration before pulling new sensor values from MainActivity and pushing them + private const val THREAD_INTERVAL = 8 + + // the sampling rate of every stream depends on the thread sleep interval, not the OS + private const val SAMPLING_RATE = + 1000 / THREAD_INTERVAL // how many values do we receive per ms + + // audio settings + private const val CHANNEL = AudioFormat.CHANNEL_IN_STEREO + private const val FORMAT = AudioFormat.ENCODING_PCM_FLOAT + + /** + * Factor by that the minimum buffer size is multiplied. The bigger the factor is the less + * likely it is that samples will be dropped, but more memory will be used. The minimum buffer + * size is determined by [AudioRecord.getMinBufferSize] and depends on the + * recording settings. + */ + private const val BUFFER_SIZE_FACTOR = 2 + + /** + * Size of the buffer where the audio data is stored by Android + */ + private val BUFFER_SIZE = + AudioRecord.getMinBufferSize(AUDIO_RECORDING_RATE, CHANNEL, FORMAT) * BUFFER_SIZE_FACTOR + } + + init { + mAudioThread = Thread { + audio = StreamInfo( + "Audio " + Build.MODEL, + "audio", + audio_channel_count, + AUDIO_RECORDING_RATE.toDouble(), + LSL.ChannelFormat.float32, + Build.FINGERPRINT + ) + try { + audioOutlet = StreamOutlet(audio) + recorder = AudioRecord( + MediaRecorder.AudioSource.MIC, + AUDIO_RECORDING_RATE, + CHANNEL, + FORMAT, + BUFFER_SIZE + ) + } catch (e: IOException) { + e.printStackTrace() + } + catch(e: SecurityException){ + Log.e(TAG,"Encountered security exception while trying to initialize audio recorder. Please check permissions.") + exitProcess(-1) + } + while (!checkFlag) { + recorder!!.startRecording() + recorder!!.read(audio_buffer, 0, audio_buffer.size, AudioRecord.READ_BLOCKING) + audioOutlet!!.push_chunk(audio_buffer) + } + } + + } + + override fun Start() { + mAudioThread.start() + } + override fun Stop() { + Log.e(TAG, "Stopping audio bridge") + checkFlag = true + try { + mAudioThread.join() + } catch (e: InterruptedException) { + } + + audioOutlet!!.close() + recorder?.release() + recorder=null + + } + } \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/AudioClassifierHelper.kt b/app/src/main/java/de/uol/neuropsy/senda/sensor/AudioClassifierHelper.kt similarity index 95% rename from app/src/main/java/de/uol/neuropsy/senda/AudioClassifierHelper.kt rename to app/src/main/java/de/uol/neuropsy/senda/sensor/AudioClassifierHelper.kt index ea68dad3..b4ff5bb2 100644 --- a/app/src/main/java/de/uol/neuropsy/senda/AudioClassifierHelper.kt +++ b/app/src/main/java/de/uol/neuropsy/senda/sensor/AudioClassifierHelper.kt @@ -1,4 +1,4 @@ -package de.uol.neuropsy.senda +package de.uol.neuropsy.senda.sensor /* * Copyright 2023 The TensorFlow Authors. All Rights Reserved. @@ -20,7 +20,6 @@ import android.annotation.SuppressLint import android.content.Context import android.media.AudioFormat import android.media.AudioRecord -import android.media.MediaRecorder import android.os.Build import android.os.SystemClock import android.util.Log @@ -30,7 +29,6 @@ import com.google.mediapipe.tasks.audio.core.RunningMode import com.google.mediapipe.tasks.components.containers.AudioData import com.google.mediapipe.tasks.components.containers.AudioData.AudioDataFormat import com.google.mediapipe.tasks.core.BaseOptions -import com.google.mediapipe.tasks.core.Delegate import edu.ucsd.sccn.LSL import java.util.concurrent.ScheduledThreadPoolExecutor import java.util.concurrent.TimeUnit @@ -42,7 +40,7 @@ class AudioClassifierHelper( var numOfResults: Int = DEFAULT_NUM_OF_RESULTS, var runningMode: RunningMode = RunningMode.AUDIO_CLIPS, var listener: ClassifierListener? = null, -) { +) : SensorBridge { private var mStreamOutlet = LSL.StreamOutlet( LSL.StreamInfo( "Audio classifier", "Marker", 1, @@ -103,28 +101,25 @@ class AudioClassifierHelper( BUFFER_SIZE_IN_BYTES.toInt() ) - startAudioClassification() } } catch (e: IllegalStateException) { listener?.onError( "Audio Classifier failed to initialize. See error logs for details" ) - Log.e( - TAG, "MP task failed to load with error: " + e.message + TAG, "MP task failed to load with illegal state: " + e.message ) } catch (e: RuntimeException) { listener?.onError( "Audio Classifier failed to initialize. See error logs for details" ) - Log.e( - TAG, "MP task failed to load with error: " + e.message + TAG, "MP task failed to load with runtime error: " + e.message ) } } - private fun startAudioClassification() { + override fun Start() { if (recorder?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { return } @@ -176,7 +171,7 @@ class AudioClassifierHelper( return null } - fun stopAudioClassification() { + override fun Stop() { if(isClosed()) { Log.e(TAG,"Trying to stop audio classification, but audio classification is not running!") } @@ -184,17 +179,16 @@ class AudioClassifierHelper( audioClassifier?.close() audioClassifier = null recorder?.stop() + recorder?.release() + recorder=null mStreamOutlet.close() mAllLabelsOutlet.close() } - fun isClosed(): Boolean { + private fun isClosed(): Boolean { return audioClassifier == null } - protected fun finalize() { - } - private fun streamAudioResultListener(resultListener: AudioClassifierResult) { resultListener.classificationResults()?.size?.let { val categories = diff --git a/app/src/main/java/de/uol/neuropsy/senda/sensor/LocationBridge.kt b/app/src/main/java/de/uol/neuropsy/senda/sensor/LocationBridge.kt new file mode 100644 index 00000000..6cb772ed --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/sensor/LocationBridge.kt @@ -0,0 +1,75 @@ +package de.uol.neuropsy.senda.sensor + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.Looper +import android.util.Log +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import edu.ucsd.sccn.LSL +import edu.ucsd.sccn.LSL.StreamInfo +import edu.ucsd.sccn.LSL.StreamOutlet +import java.io.IOException + +class LocationBridge internal constructor(context: Context?) : SensorBridge { + // GoogleApiClient instance to connect to Google Play Services + private val mlocationProviderClient: FusedLocationProviderClient + private val mlocationRequest: LocationRequest + private val mlocationCallback: LocationCallback + private var mStreamOutlet: StreamOutlet? = null + + init { + val mStreamInfo = StreamInfo( + "Location" + " " + Build.MODEL, + "other", 4, LSL.IRREGULAR_RATE, LSL.ChannelFormat.float32, Build.FINGERPRINT + ) + try { + mStreamOutlet = StreamOutlet(mStreamInfo) + } catch (e: IOException) { + Log.e("LocationBridge", e.toString()) + e.printStackTrace() + } + if (context == null) Log.e("LocationBridge", "Context is null!") + mlocationProviderClient = LocationServices.getFusedLocationProviderClient(context!!) + mlocationRequest = + LocationRequest.Builder(1000).setPriority(Priority.PRIORITY_HIGH_ACCURACY).build() + mlocationCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + // Handle the received location updates + if (locationResult != null) { + val location = locationResult.lastLocation + if (location != null) { + val loc = doubleArrayOf( + location.latitude, + location.longitude, + location.altitude, + location.accuracy.toDouble() + ) + mStreamOutlet!!.push_sample(loc) + } + } + } + } + } + + @SuppressLint("MissingPermission") + override fun Start() { + mlocationProviderClient.requestLocationUpdates(mlocationRequest, mlocationCallback, Looper.getMainLooper()) + } + + override fun Stop() { + mlocationProviderClient.removeLocationUpdates(mlocationCallback) + mStreamOutlet!!.close() + } + + protected fun finalize() {} + + companion object { + var TAG = LocationBridge::class.java.simpleName + } +} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/sensor/MovellaBridge.kt b/app/src/main/java/de/uol/neuropsy/senda/sensor/MovellaBridge.kt new file mode 100644 index 00000000..8856a84c --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/sensor/MovellaBridge.kt @@ -0,0 +1,153 @@ +package de.uol.neuropsy.senda.sensor + +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.os.Build +import android.util.Log +import com.xsens.dot.android.sdk.events.DotData +import com.xsens.dot.android.sdk.interfaces.DotDeviceCallback +import com.xsens.dot.android.sdk.models.DotDevice +import com.xsens.dot.android.sdk.models.DotPayload +import com.xsens.dot.android.sdk.models.FilterProfileInfo +import edu.ucsd.sccn.LSL +import edu.ucsd.sccn.LSL.StreamInfo +import edu.ucsd.sccn.LSL.StreamOutlet +import kotlinx.coroutines.CompletableDeferred +import java.io.IOException + +class MovellaBridge(context: Context, btDevice: BluetoothDevice?, private val initListener: () -> Unit) : + DotDeviceCallback { + private val initDeferred = CompletableDeferred() + private var mMarkerStreamInfo: StreamInfo? = null + private var mMarkerStreamOutlet: StreamOutlet? = null + private var mDataStreamInfo: StreamInfo? = null + private var mDataStreamOutlet: StreamOutlet? = null + val handle: DotDevice? + get() = if (!mDevice.isInitDone) { + null + } else mDevice + private val mContext = context + private val mDevice = DotDevice(mContext, btDevice, this) + + fun Initialize(){ + mDevice.connect() + } + + fun Start() { + try { + mMarkerStreamOutlet = StreamOutlet(mMarkerStreamInfo) + } catch (e: IOException) { + Log.e(TAG, e.toString()) + e.printStackTrace() + } + try { + mDataStreamOutlet = StreamOutlet(mDataStreamInfo) + } catch (e: IOException) { + Log.e(TAG, e.toString()) + e.printStackTrace() + } + assert(mDataStreamOutlet != null) + mDevice.startMeasuring() + Log.i(TAG, displayName + " StartMeasuring") + } + + fun Stop() { + Log.e("MovellaBridge", displayName + " " + mDevice.connectionState) + if (mDevice.connectionState == DotDevice.CONN_STATE_CONNECTED) { + Log.e("MovellaBridge", displayName + " " + mDevice.measurementState) + if (mDevice.measurementState == DotDevice.MEASUREMENT_STATE_ON) mDevice.stopMeasuring() + } + Log.e("MovellaBridge", displayName + ": Finished handling device") + if (mDataStreamOutlet != null) { + Log.e("MovellaBridge", displayName + ": Close data stream") + mDataStreamOutlet!!.close() + mDataStreamOutlet = null + } + if (mMarkerStreamOutlet != null) { + Log.e("MovellaBridge", displayName + ": Close marker stream") + mMarkerStreamOutlet!!.close() + mMarkerStreamOutlet = null + } + } + + fun Disconnect(){ + mDevice.disconnect() + } + + fun IsSynced() : Boolean { + return mDevice.isSynced + } + + fun Address() : String { + return mDevice.address + } + + val displayName: String + get() = mDevice.name + " " + mDevice.tag + + override fun onDotConnectionChanged(s: String, i: Int) {} + override fun onDotServicesDiscovered(s: String, i: Int) {} + override fun onDotFirmwareVersionRead(s: String, s1: String) {} + override fun onDotTagChanged(s: String, s1: String) {} + override fun onDotBatteryChanged(s: String, i: Int, i1: Int) {} + override fun onDotDataChanged(s: String, dotData: DotData) { + Log.v("MovellaBridge","Got data from $s: ${dotData.sampleTimeFine}") + val data = FloatArray(7) + for (i in 0..2) { + data[i] = dotData.freeAcc[i] + data[i + 3] = dotData.euler[i].toFloat() + } + data[6] = dotData.sampleTimeFine.toFloat() + if (mDataStreamOutlet != null) { + mDataStreamOutlet!!.push_sample(data) + } else { + Log.e(TAG, displayName + " mStreamOutlet is Null!") + } + } + + override fun onDotInitDone(s: String) { + Log.i( + TAG, + "Movella initialized " + s + " " + mDevice.tag + " " + mDevice.serialNumber + "!" + ) + mDevice.measurementMode = DotPayload.PAYLOAD_TYPE_COMPLETE_EULER + mDataStreamInfo = StreamInfo( + displayName, + "imu", + 7, + mDevice.currentOutputRate.toDouble(), + LSL.ChannelFormat.float32, + Build.FINGERPRINT + ) + mMarkerStreamInfo = StreamInfo( + displayName + " Marker", + "Markers", + 1, + LSL.IRREGULAR_RATE, + LSL.ChannelFormat.string, + Build.FINGERPRINT + ) + initDeferred.complete(Unit) + } + + /** suspend until onDotInitDone callback fires */ + suspend fun awaitInit() = initDeferred.await() + + override fun onDotButtonClicked(s: String, l: Long) { + val sample = arrayOfNulls(1) + sample[0] = mDevice.tag + Log.i(TAG, displayName + " button pressed!") + mMarkerStreamOutlet?.push_sample(sample) + } + + override fun onDotPowerSavingTriggered(s: String) {} + override fun onReadRemoteRssi(s: String, i: Int) {} + override fun onDotOutputRateUpdate(s: String, i: Int) {} + override fun onDotFilterProfileUpdate(s: String, i: Int) {} + override fun onDotGetFilterProfileInfo(s: String, arrayList: ArrayList) {} + override fun onSyncStatusUpdate(s: String, b: Boolean) {} + + companion object { + var TAG:String = MovellaBridge::class.java.simpleName + } +} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/sensor/MovellaMetadata.kt b/app/src/main/java/de/uol/neuropsy/senda/sensor/MovellaMetadata.kt new file mode 100644 index 00000000..c001314f --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/sensor/MovellaMetadata.kt @@ -0,0 +1,93 @@ +package de.uol.neuropsy.senda.sensor + +import android.bluetooth.BluetoothDevice +import android.content.Context +import com.xsens.dot.android.sdk.events.DotData +import com.xsens.dot.android.sdk.interfaces.DotDeviceCallback +import com.xsens.dot.android.sdk.models.DotDevice +import com.xsens.dot.android.sdk.models.FilterProfileInfo +import com.xsens.dot.android.sdk.settings.DotSettingsManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import java.util.ArrayList +import java.util.concurrent.ConcurrentHashMap + +object MovellaMetadata { + private val nameCache = ConcurrentHashMap() + private val mutexes = ConcurrentHashMap() + + /** + * Connects to the DotDevice, waits for init, reads tag, then disconnects and waits for + * the disconnect to complete. + */ + suspend fun getDeviceName( + context: Context, + device: BluetoothDevice, + timeoutMs: Long = 5_000L + ): String = coroutineScope { + // fast-path if we already fetched it + nameCache[device.address]?.let { return@coroutineScope it } + + // serialize access per device + val mutex = mutexes.getOrPut(device.address) { Mutex() } + val tag = mutex.withLock { + // re-check inside the lock + nameCache[device.address]?.let { return@withLock it } + + // prepare to wait for init & disconnect events + val initDeferred = CompletableDeferred() + val disconnectDeferred = CompletableDeferred() + + val callback = object : DotDeviceCallback { + override fun onDotInitDone(address: String?) { + if (address == device.address) initDeferred.complete(Unit) + } + override fun onDotConnectionChanged(address: String?, status: Int) { + if (address == device.address && + status == DotDevice.CONN_STATE_DISCONNECTED + ) disconnectDeferred.complete(Unit) + } + // all other callbacks no-op + override fun onDotServicesDiscovered(p0: String?, p1: Int) {} + override fun onDotFirmwareVersionRead(p0: String?, p1: String?) {} + override fun onDotTagChanged(p0: String?, p1: String?) {} + override fun onDotBatteryChanged(p0: String?, p1: Int, p2: Int) {} + override fun onDotDataChanged(p0: String?, p1: DotData?) {} + override fun onDotButtonClicked(p0: String?, p1: Long) {} + override fun onDotPowerSavingTriggered(p0: String?) {} + override fun onReadRemoteRssi(p0: String?, p1: Int) {} + override fun onDotOutputRateUpdate(p0: String?, p1: Int) {} + override fun onDotFilterProfileUpdate(p0: String?, p1: Int) {} + override fun onDotGetFilterProfileInfo( + p0: String?, p1: ArrayList? + ) {} + override fun onSyncStatusUpdate(p0: String?, p1: Boolean) {} + } + // connect and wait for init + val dot = DotDevice(context, device, callback) + dot.connect() + withTimeout(timeoutMs) { + initDeferred.await() + } + + // read the tag + val discoveredTag = dot.tag + nameCache[device.address] = discoveredTag + + // disconnect and wait for it + dot.disconnect() + withTimeout(timeoutMs) { + disconnectDeferred.await() + } + + // return from withLock + discoveredTag + } + + // return from coroutineScope + tag + } +} diff --git a/app/src/main/java/de/uol/neuropsy/senda/sensor/OnboardSensorBridge.kt b/app/src/main/java/de/uol/neuropsy/senda/sensor/OnboardSensorBridge.kt new file mode 100644 index 00000000..21d3c5ea --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/sensor/OnboardSensorBridge.kt @@ -0,0 +1,54 @@ +package de.uol.neuropsy.senda.sensor + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build +import android.util.Log +import de.uol.neuropsy.senda.utils.Utils.SimpleSensorType +import edu.ucsd.sccn.LSL +import edu.ucsd.sccn.LSL.StreamInfo +import edu.ucsd.sccn.LSL.StreamOutlet +import java.io.IOException + +class OnboardSensorBridge internal constructor(dataSize: Int, var mSensor: Sensor, context : Context) : SensorEventListener, de.uol.neuropsy.senda.sensor.SensorBridge { + private val mStreamInfo: StreamInfo + private var mStreamOutlet: StreamOutlet? = null + private val sensorManager = context.getSystemService(SensorManager::class.java) + init { + mSensor.stringType + mStreamInfo = StreamInfo( + SimpleSensorType(mSensor.type) + " " + Build.MODEL, + mSensor.stringType.removePrefix("android.sensor."), dataSize, LSL.IRREGULAR_RATE, LSL.ChannelFormat.float32, Build.FINGERPRINT + ) + Log.e(TAG, "Created bridge for " + mStreamInfo.name()) + } + + override fun Start() { + sensorManager.registerListener(this,mSensor, SensorManager.SENSOR_DELAY_UI) + try { + mStreamOutlet = StreamOutlet(mStreamInfo) + } catch (e: IOException) { + Log.e("SensorBridge", e.toString()) + e.printStackTrace() + } + } + + override fun Stop() { + sensorManager.unregisterListener(this) + mStreamOutlet?.close() + mStreamOutlet=null + } + + override fun onSensorChanged(sensorEvent: SensorEvent) { + mStreamOutlet?.push_chunk(sensorEvent.values) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} + + companion object { + var TAG = OnboardSensorBridge::class.java.simpleName + } +} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/sensor/SensorBridge.kt b/app/src/main/java/de/uol/neuropsy/senda/sensor/SensorBridge.kt new file mode 100644 index 00000000..6405bf80 --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/sensor/SensorBridge.kt @@ -0,0 +1,13 @@ +package de.uol.neuropsy.senda.sensor + +/** + * Interface defining an abstract sensor bridge that starts and stops + * its corresponding sensor and LSL streaming + * and calls all necessary os functions internally + */ +interface SensorBridge { + /// Start streaming + fun Start() + /// Stop streaming + fun Stop() +} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/sensor/SensorConfig.kt b/app/src/main/java/de/uol/neuropsy/senda/sensor/SensorConfig.kt new file mode 100644 index 00000000..3b4a288d --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/sensor/SensorConfig.kt @@ -0,0 +1,45 @@ +package de.uol.neuropsy.senda.sensor + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents a sensor unique id and the corresponding human readable name to be streamed by LSLService. + * Can be either an onboard sensor (identified by type) or a Movella device + * (identified by its Bluetooth MAC address). + */ +sealed class SensorConfig(open val name : String) : Parcelable { + /** + * Onboard sensors available on the device, e.g., accelerometer, gyroscope. + * @param name a human-readable name for the onboard sensor. + * @param type the constant the sensor manager uses to describe the sensor + */ + @Parcelize + data class Onboard( + override val name: String, + val type: Int + ) : SensorConfig(name) + + /** + * External Movella sensor connected via Bluetooth. + * @param address the MAC address of the Movella device. + * @param tag the human-readable name for the the Movella device. + */ + @Parcelize + data class Movella( + val address: String, + val tag: String + ) : SensorConfig(tag) + + /** + * GPS sensor + */ + @Parcelize + object Location : SensorConfig("Location") + + @Parcelize + object Audio : SensorConfig("Audio") + + @Parcelize + object AudioClassification : SensorConfig("Audio Classification") +} diff --git a/app/src/main/java/de/uol/neuropsy/senda/service/LSLService.kt b/app/src/main/java/de/uol/neuropsy/senda/service/LSLService.kt new file mode 100644 index 00000000..a194407d --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/service/LSLService.kt @@ -0,0 +1,282 @@ +package de.uol.neuropsy.senda.service + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.Color +import android.hardware.SensorManager +import android.net.wifi.WifiManager +import android.net.wifi.WifiManager.MulticastLock +import android.os.Binder +import android.os.IBinder +import android.os.PowerManager +import android.os.PowerManager.WakeLock +import android.util.Log +import android.widget.Toast +import androidx.core.app.NotificationCompat +import com.google.mediapipe.tasks.audio.core.RunningMode +import com.xsens.dot.android.sdk.interfaces.DotSyncCallback +import com.xsens.dot.android.sdk.models.DotDevice +import com.xsens.dot.android.sdk.models.DotSyncManager +import de.uol.neuropsy.senda.R +import de.uol.neuropsy.senda.data.SyncStatus +import de.uol.neuropsy.senda.sensor.AudioBridge +import de.uol.neuropsy.senda.sensor.AudioClassifierHelper +import de.uol.neuropsy.senda.sensor.LocationBridge +import de.uol.neuropsy.senda.sensor.MovellaBridge +import de.uol.neuropsy.senda.sensor.SensorBridge +import de.uol.neuropsy.senda.sensor.OnboardSensorBridge +import de.uol.neuropsy.senda.sensor.SensorConfig +import de.uol.neuropsy.senda.utils.Utils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +sealed class ServiceEvent { + object Started : ServiceEvent() + object Stopped : ServiceEvent() + object Configured : ServiceEvent() + class Syncing(val progress : Int) : ServiceEvent() + data class Failed(val error: String) : ServiceEvent() +} + +class LSLService : Service() { + private val binder = LocalBinder() + private val _events = MutableSharedFlow(replay = 1) + val events: SharedFlow = _events + private var sensorBridges : MutableList = mutableListOf() + private var movellaBridges : MutableList = mutableListOf() + + //Wake Lock + private lateinit var wakelock: WakeLock + private lateinit var multicastLock: MulticastLock + + inner class LocalBinder : Binder() { + fun getService(): LSLService = this@LSLService + } + override fun onBind(intent: Intent): IBinder = binder + + @SuppressLint("WakelockTimeout") + override fun onCreate() { + super.onCreate() + val pm = getSystemService(PowerManager::class.java) + wakelock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, javaClass.canonicalName) + wakelock.acquire() + val wifi = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + multicastLock = wifi.createMulticastLock("LSLService") + multicastLock.acquire() + + Thread.setDefaultUncaughtExceptionHandler { _, _ -> + stopStreaming() + } + } + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + // startForeground() MUST be called synchronously here, before onStartCommand returns. + // The FGS type is passed by the caller so we only request location/microphone types + // when those sensors are actually selected and their runtime permissions are granted. + val fgsType = intent.getIntExtra( + EXTRA_FGS_TYPE, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + ) + try { + createNotificationChannel() + startForegroundServiceNotification(fgsType) + } catch (e: Throwable) { + Log.e(TAG, "Failed to start LSLService as foreground service", e) + CoroutineScope(Dispatchers.Main).launch { + _events.emit(ServiceEvent.Failed(e.message ?: "Unknown error")) + } + stopSelf() + } + return START_NOT_STICKY + } + + private fun startForegroundServiceNotification(fgsType: Int) { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher_round) + .setContentTitle("SENDA is running") + .setOngoing(true) + .build() + startForeground(NOTIF_ID, notification, fgsType) + } + + @SuppressLint("MissingPermission") + suspend fun configureSensors(configs: List) { + sensorBridges.clear() + movellaBridges.clear() + + val btManager=applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val bluetoothAdapter = btManager.adapter + ?: throw IllegalStateException("Device doesn't support Bluetooth") + val sm = getSystemService(SensorManager::class.java) + for (cfg in configs) { + when (cfg) { + is SensorConfig.Onboard -> { + sm.getDefaultSensor(cfg.type)?.let { sensor-> + sensorBridges.add(OnboardSensorBridge(Utils.getChannelCount(sensor), sensor, applicationContext)) + } + } + is SensorConfig.Movella -> { + val btDevice=bluetoothAdapter.getRemoteDevice(cfg.address) + val mb=MovellaBridge(applicationContext,btDevice){} + movellaBridges+=mb + mb.Initialize() + } + is SensorConfig.Audio -> { + sensorBridges.add(AudioBridge(this@LSLService)) + } + is SensorConfig.AudioClassification -> { + sensorBridges.add(AudioClassifierHelper( + this@LSLService, + AudioClassifierHelper.DISPLAY_THRESHOLD, + AudioClassifierHelper.DEFAULT_OVERLAP, + AudioClassifierHelper.DEFAULT_NUM_OF_RESULTS, + RunningMode.AUDIO_STREAM, + null + )) + } + is SensorConfig.Location -> { + sensorBridges.add(LocationBridge(this@LSLService)) + } + } + } + // await **all** the onDotInitDone callbacks + movellaBridges.forEach { it.awaitInit() } + _events.emit(ServiceEvent.Configured) + } + + suspend fun startStreaming(){ + sensorBridges.forEach { + it.Start() + } + + if(movellaBridges.size>1 /*TODO and syncing is active in settings*/){ + val terminalStatus: SyncStatus = syncMovellaDevices() + .onEach { status -> + when (status) { + is SyncStatus.Progress -> + _events.tryEmit(ServiceEvent.Syncing(status.progress)) + else -> { /* ignore */ } + } + } + .filter { it is SyncStatus.Success || it is SyncStatus.Failed } + .first() + when (terminalStatus) { + is SyncStatus.Failed -> + _events.tryEmit(ServiceEvent.Failed("Could not sync Movella devices!")) + else -> {} + } + } + movellaBridges.forEach { it.Start() } + _events.emit(ServiceEvent.Started) + } + + fun stopStreaming(){ + sensorBridges.forEach { + it.Stop() + } + movellaBridges.forEach { it.Stop() } + stopSyncing() + movellaBridges.forEach { it.Disconnect() } + movellaBridges.clear() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + _events.tryEmit(ServiceEvent.Stopped) + } + + private fun syncMovellaDevices(): Flow = callbackFlow { + // Set the first device as the root + movellaBridges[0].handle?.isRootDevice = true + var syncSuccessful=false + val syncCallback = object : DotSyncCallback { + override fun onSyncingStarted(deviceAddress: String?, isRoot: Boolean, count: Int) { + trySend(SyncStatus.Progress(0)) + } + override fun onSyncingProgress(progress: Int, total: Int) { + trySend(SyncStatus.Progress(progress)) + } + override fun onSyncingResult(deviceAddress: String?, success: Boolean, reason: Int) { + // No-op + } + override fun onSyncingDone(results: HashMap, allSuccessful: Boolean, code: Int) { + syncSuccessful=allSuccessful + if(allSuccessful) + trySend(SyncStatus.Success()) + else + trySend(SyncStatus.Failed()) + close() + } + override fun onSyncingStopped(deviceAddress: String?, isSuccess: Boolean, code: Int) { + } + } + DotSyncManager.getInstance(syncCallback).startSyncing(ArrayList(movellaBridges.map { it.handle }), 1) + awaitClose { if(!syncSuccessful) DotSyncManager.getInstance(syncCallback).stopSyncing() + } + } + + + private fun stopSyncing() : Flow = callbackFlow { + val syncCallback = object : DotSyncCallback { + override fun onSyncingStarted(deviceAddress: String?, isRoot: Boolean, count: Int) { + } + override fun onSyncingProgress(progress: Int, total: Int) { + } + override fun onSyncingResult(deviceAddress: String?, success: Boolean, reason: Int) { + } + override fun onSyncingDone(results: HashMap, allSuccessful: Boolean, code: Int) { + } + override fun onSyncingStopped(deviceAddress: String?, isSuccess: Boolean, code: Int) { + } + } + //movellaBridges.map { it.handle }.filterNotNull().toTypedArray() + DotSyncManager.getInstance(syncCallback).stopSyncing() + } + private fun createNotificationChannel() { + val chan = NotificationChannel( + CHANNEL_ID, + "SENDA Background Service", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + lightColor = Color.GREEN + lockscreenVisibility = Notification.VISIBILITY_PRIVATE + } + getSystemService(NotificationManager::class.java).createNotificationChannel(chan) + } + + override fun onDestroy() { + super.onDestroy() + stopStreaming() + wakelock.release() + multicastLock.release() + _events.tryEmit(ServiceEvent.Stopped) + } + + override fun onTaskRemoved(rootIntent: Intent?) { + stopStreaming() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + super.onTaskRemoved(rootIntent) + } + + + companion object { + const val EXTRA_FGS_TYPE = "de.uol.neuropsy.senda.FGS_TYPE" + private const val TAG = "LSLService" + private const val CHANNEL_ID = "de.uol.neuropsy.senda.channel" + private const val NOTIF_ID = 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/service/LSLServiceClient.kt b/app/src/main/java/de/uol/neuropsy/senda/service/LSLServiceClient.kt new file mode 100644 index 00000000..7c484f8f --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/service/LSLServiceClient.kt @@ -0,0 +1,127 @@ +package de.uol.neuropsy.senda.service + +import android.app.Application +import android.content.* +import android.content.pm.ServiceInfo +import android.os.IBinder +import android.util.Log +import androidx.core.content.ContextCompat +import de.uol.neuropsy.senda.sensor.SensorConfig +import de.uol.neuropsy.senda.ui.state.UiState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout + +/** + * Interface defining a client of LSLService that manages binding + * and controls sensor streaming lifecycle. + */ +interface LSLServiceClient { + /** + * Binds to LSLService (if not already), configures sensors, and starts streaming. + */ + suspend fun bindAndStart(configs: List): ServiceEvent + + /** + * Stops streaming but keeps the service bound. + */ + fun stop() + + /** + * Unbinds from LSLService and releases resources. + */ + fun unbind() +} + +/** + * Concrete implementation of LSLServiceClient that handles + * ServiceConnection and lifecycle of LSLService. + */ +class LSLServiceClientImpl( + private val application: Application +) : LSLServiceClient { + private val startStopMutex = Mutex() + private var service: LSLService? = null + private var boundDeferred: CompletableDeferred? = null + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + service = (binder as LSLService.LocalBinder).getService() + boundDeferred?.complete(Unit) + } + + override fun onServiceDisconnected(name: ComponentName) { + service = null + } + } + + override suspend fun bindAndStart(configs: List) = startStopMutex.withLock { + // Bind only once + if (service == null) { + boundDeferred = CompletableDeferred().apply { + invokeOnCompletion { if (isCancelled) cancel() } + } + try { + // Compute the minimum FGS type required for the selected sensors. + // connectedDevice is always included (no runtime permission needed). + // microphone/location are added only when those sensor types are selected, + // avoiding the SecurityException that fires when the matching runtime + // permission (RECORD_AUDIO / ACCESS_FINE_LOCATION) hasn't been granted yet. + val intent = Intent(application, LSLService::class.java).apply { + putExtra(LSLService.EXTRA_FGS_TYPE, fgsTypeFor(configs)) + } + ContextCompat.startForegroundService(application, intent) + application.bindService(intent, connection, Context.BIND_AUTO_CREATE) + boundDeferred!!.await() + } catch (e: Throwable) { + unbind() + throw e + } + } + + try { + withTimeout(15_000L) { + service!!.configureSensors(configs) + service!!.events.first { it is ServiceEvent.Configured } + } + service!!.startStreaming() + service!!.events.first { it is ServiceEvent.Started } + } catch (e: Throwable){ + if (service!=null) { + unbind() + } + throw e + } + } + + override fun stop() { + service?.stopStreaming() + } + + override fun unbind() { + application.unbindService(connection) + service = null + } + + companion object { + /** Computes the minimum foreground service type bitmask for the given sensor configs. */ + fun fgsTypeFor(configs: List): Int { + // connectedDevice requires no runtime permission — always safe as the base. + var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + for (cfg in configs) { + when (cfg) { + is SensorConfig.Audio, + is SensorConfig.AudioClassification -> + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + is SensorConfig.Location -> + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + else -> { /* onboard / Movella: connectedDevice already set */ } + } + } + return type + } + } +} diff --git a/app/src/main/java/de/uol/neuropsy/senda/ui/main/MainActivity.kt b/app/src/main/java/de/uol/neuropsy/senda/ui/main/MainActivity.kt new file mode 100644 index 00000000..c6da9b31 --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/ui/main/MainActivity.kt @@ -0,0 +1,424 @@ +package de.uol.neuropsy.senda.ui.main + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.text.Html +import android.text.method.LinkMovementMethod +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.LinearInterpolator +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.CheckedTextView +import android.widget.ListView +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.activity.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.lifecycle.lifecycleScope +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.appbar.MaterialToolbar +import de.uol.neuropsy.senda.R +import de.uol.neuropsy.senda.data.SensorRepositoryImpl +import de.uol.neuropsy.senda.service.LSLService +import de.uol.neuropsy.senda.ui.state.UiState +import de.uol.neuropsy.senda.ui.tutorial.TutorialActivity +import de.uol.neuropsy.senda.utils.PermissionManager +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + + private val REQ_BACKGROUND_LOCATION= 4711 + private val viewModel: MainViewModel by viewModels { + MainViewModelFactory(application, SensorRepositoryImpl(applicationContext)) + } + private lateinit var sensorListView: ListView + private lateinit var swipeRefreshLayout: SwipeRefreshLayout + private lateinit var startButton: Button + private lateinit var stopButton: Button + private lateinit var streamingStatus: TextView + private lateinit var progressBar: ProgressBar + private lateinit var sensorAdapter: ArrayAdapter + private lateinit var permissionManager : PermissionManager + private var lslService: LSLService? = null + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + val local = binder as LSLService.LocalBinder + lslService = local.getService() + lifecycleScope.launchWhenStarted { + lslService!!.events.collect { viewModel.onServiceEvent(it) } + } + } + override fun onServiceDisconnected(name: ComponentName) { + lslService = null + } + } + + private fun bindService() { + val intent = Intent(this, LSLService::class.java) + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + + private fun unbindService() { + lslService?.let { unbindService(serviceConnection) } + lslService = null + } + + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + val toolbar: MaterialToolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + + // Android 15+ enforces edge-to-edge: the app draws behind the status bar and + // navigation bar. Apply system bar insets so the toolbar isn't hidden behind + // the status bar, and the bottom buttons aren't hidden behind the nav bar. + val rootView = findViewById(R.id.activity_lsldemo) + ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + // Push toolbar content below the status bar; toolbar background fills the gap. + toolbar.updatePadding(top = bars.top) + // Keep bottom content above the navigation bar. + rootView.updatePadding(bottom = bars.bottom) + insets + } + bindViews() + bindListeners() + permissionManager = PermissionManager(this) + lifecycleScope.launch { + viewModel.uiState.collectLatest { state -> + render(state) + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater: MenuInflater = menuInflater + inflater.inflate(R.menu.menu_main, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_tutorial -> { + val intent = Intent(this, TutorialActivity::class.java) + startActivity(intent) + true + } + R.id.action_about -> { + showAboutDialog() + true + } + + else -> false + } + } + + + override fun onStart() { + super.onStart() + bindService() + } + + override fun onStop() { + super.onStop() + unbindService() + } + + + override fun onDestroy() { + super.onDestroy() + viewModel.stopStreaming() + } + + private fun render(state: UiState) { + // 1) Always hide all transient indicators at the start: + swipeRefreshLayout.isRefreshing = false + progressBar.visibility = View.GONE + streamingStatus.clearAnimation() + streamingStatus.visibility = View.INVISIBLE + + // 2) Then handle each state: + when (state) { + UiState.Idle -> { + sensorListView.isEnabled=true + sensorListView.alpha=1.0f + startButton.isEnabled = true + stopButton.isEnabled = false + } + is UiState.Scanning -> { + swipeRefreshLayout.isRefreshing = true + startButton.isEnabled = false + stopButton.isEnabled = false + } + is UiState.DevicesDiscovered -> { + startButton.isEnabled = true + stopButton.isEnabled = false + bindDeviceList(state.sensorNames) + } + is UiState.Syncing -> { + sensorListView.isEnabled=false + progressBar.visibility = View.VISIBLE + progressBar.progress = state.progress + startButton.isEnabled = false + stopButton .isEnabled = false + } + is UiState.Streaming -> { + sensorListView.isEnabled=false + sensorListView.alpha=0.1f + startButton.isEnabled = false + stopButton.isEnabled = true + // kick off the pulsing animation + val anim = AlphaAnimation(0.5f, 0f).apply { + duration = 850 + interpolator = LinearInterpolator() + repeatCount = Animation.INFINITE + repeatMode = Animation.REVERSE + } + streamingStatus.visibility = View.VISIBLE + streamingStatus.startAnimation(anim) + } + is UiState.Starting -> { + sensorListView.isEnabled=false + sensorListView.alpha=0.1f + startButton.isEnabled = false + stopButton.isEnabled = true + } + is UiState.Stopping -> { + // No action needed beyond getting rid of + // transient indicators. + } + is UiState.Error -> { + Toast.makeText(this, state.message, Toast.LENGTH_LONG).show() + // then immediately ask VM to clear error: + viewModel.clearError() + } + } + } + + // Helper to update the ListView adapter in one shot + private fun bindDeviceList(sensorNames: List) { + sensorAdapter.clear() + sensorAdapter.addAll(sensorNames) + sensorAdapter.notifyDataSetChanged() + } + + private fun bindViews() { + sensorListView = findViewById(R.id.sensors) + swipeRefreshLayout = findViewById(R.id.swiperefresh) + startButton = findViewById(R.id.startLSL) + stopButton = findViewById(R.id.stopLSL) + streamingStatus = findViewById(R.id.streamingNow) + progressBar = findViewById(R.id.progressBar) + + sensorAdapter = ArrayAdapter(this, + R.layout.list_view_text, + R.id.streamsSelected, mutableListOf()) + sensorListView.adapter = sensorAdapter + sensorListView.choiceMode = ListView.CHOICE_MODE_MULTIPLE + } + + private fun bindListeners() { + swipeRefreshLayout.setOnRefreshListener { + // Determine which Bluetooth permissions we need on this API level + val bluetoothPerms = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT + ) + } else { + arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION) // pre-S requires location for BLE scans + } + + if (bluetoothPerms.isNotEmpty()) { + lifecycleScope.launch { + val result = permissionManager + .requestPermissions(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT) + if (result.values.all { it }) { + viewModel.startScan() + } else { + swipeRefreshLayout.isRefreshing = false + Toast.makeText(this@MainActivity, "Bluetooth permission required", Toast.LENGTH_SHORT).show() + } + } + + } else { + // No runtime perms needed: go right ahead + viewModel.startScan() + } + } + + sensorListView.setOnItemClickListener { parent, view, position, _ -> + val name = parent.getItemAtPosition(position) as String + val isChecked = (view as CheckedTextView).isChecked + + if (!isChecked) { + // Un‐checking is always allowed + return@setOnItemClickListener + } + + when (name) { + "Location" -> { + // Step 1: ask for fine location via our PermissionManager + lifecycleScope.launch { + val result = permissionManager + .requestPermissions(android.Manifest.permission.ACCESS_FINE_LOCATION) + if (result[android.Manifest.permission.ACCESS_FINE_LOCATION] == true) { + // Step 2: now ask for background via ActivityCompat + ActivityCompat.requestPermissions( + this@MainActivity, + arrayOf(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION), + REQ_BACKGROUND_LOCATION + ) + } else { + // user denied fine => rollback + sensorListView.setItemChecked(position, false) + Toast.makeText( + this@MainActivity, + "Fine location required for Location sensor", + Toast.LENGTH_SHORT + ).show() + } + } + } + + "Audio","Audio Classification"->{ + askForPermissions( + position, name, + arrayOf(android.Manifest.permission.RECORD_AUDIO) + ) + } + else -> { + //No special permissions needed, no-op + } + } + } + + startButton.setOnClickListener { + // Gather which sensors the user checked: + val selectedSensors = sensorAdapter + .getAllItems() + .filter { sensorListView.isItemChecked(sensorAdapter.getPosition(it)) } + + if (selectedSensors.isEmpty()) { + Toast.makeText(this, "Please select at least one sensor", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + // Launch our new single-entrypoint in the ViewModel: + lifecycleScope.launch { + viewModel.startSelectedSensors(selectedSensors) + } + } + + stopButton.setOnClickListener { + viewModel.stopStreaming() // cancel any sync + stop the service + } + } + + private fun askForPermissions(position: Int, sensor: String, perms: Array) { + lifecycleScope.launch { + val results = permissionManager.requestPermissions(*perms) + if (results.values.all { it }) { + // leave the checkbox checked + } else { + // user denied → revert + sensorListView.setItemChecked(position, false) + Toast.makeText( + this@MainActivity, + "$sensor permission required", + Toast.LENGTH_SHORT + ).show() + } + } + } + + // Special treatment only for background location - We need to ask for it in the legacy way + override fun onRequestPermissionsResult(requestCode: Int,permissions: Array,grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + REQ_BACKGROUND_LOCATION -> { + // See if background was granted + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Accept the checkbox (it’s already checked) + } else { + // rollback the “Location” checkbox + val pos = (0 until sensorAdapter.count) + .first { sensorAdapter.getItem(it) == "Location" } + sensorListView.setItemChecked(pos, false) + Toast.makeText( + this, + "Background location is required for Location sensor", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + private fun showAboutDialog() { + val pm: PackageManager = applicationContext.packageManager + val pkg: String = applicationContext.packageName + val appName: String = applicationContext.applicationInfo.loadLabel(pm).toString() + val version: String? = try { + pm.getPackageInfo(pkg, 0).versionName + } catch (e: PackageManager.NameNotFoundException) { + "?.?.?" + } + + val html = """ +

About $appName

+

Version $version
© 2025 Carl von Ossietzky Universität Oldenburg.

+

For more information, visit + our Github repo. +

+

Credits

+ + """.trimIndent() + + // 2) Inflate a TextView in code, apply padding and HTML + val tv = TextView(applicationContext).apply { + val pad = (resources.displayMetrics.density * 16).toInt() + setPadding(pad, pad, pad, pad) + text = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY) + movementMethod = LinkMovementMethod.getInstance() + } + + // 3) Build and show + MaterialAlertDialogBuilder(this) + .setView(tv) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + private fun ArrayAdapter.getAllItems(): List { + val result = mutableListOf() + for (i in 0 until count) { + getItem(i)?.let { result.add(it) } + } + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/ui/main/MainViewModel.kt b/app/src/main/java/de/uol/neuropsy/senda/ui/main/MainViewModel.kt new file mode 100644 index 00000000..1325c12c --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/ui/main/MainViewModel.kt @@ -0,0 +1,113 @@ +// MainViewModel.kt (with SensorRepository integration) +package de.uol.neuropsy.senda.ui.main + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import de.uol.neuropsy.senda.data.SensorRepositoryImpl +import de.uol.neuropsy.senda.service.LSLServiceClient +import de.uol.neuropsy.senda.service.LSLServiceClientImpl +import de.uol.neuropsy.senda.service.ServiceEvent +import de.uol.neuropsy.senda.ui.state.UiState +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +class MainViewModelFactory( + private val app: Application, + private val repository: SensorRepositoryImpl +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MainViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return MainViewModel(app, repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} + +class MainViewModel(application: Application, private val repository: SensorRepositoryImpl) : AndroidViewModel(application) { + private var streamingJob : Job?=null + private val serviceClient : LSLServiceClient = LSLServiceClientImpl(application) + // UI state + private val _uiState = MutableStateFlow(UiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Emit initial onboard sensors + val onboard = repository.getAvailableOnboardSensors() + _uiState.value = UiState.DevicesDiscovered(onboard.map { it.name }) + } + + /** + * Start BLE scan using repository + */ + /** Populates discoveredBridges and emits a combined state */ + fun startScan() { + viewModelScope.launch { + repository.scanForMovellaDevices() + .onStart { _uiState.value = UiState.Scanning } + .onCompletion { _uiState.value=UiState.Idle } + .collect { devices -> + // Emit both onboard & movella lists + val onboard = repository.getAvailableOnboardSensors() + _uiState.value = UiState.DevicesDiscovered( + onboard.map { it.name }+devices.map { it.name } + ) + } + } + } + + fun startSelectedSensors(selected: List) { + _uiState.value=UiState.Starting + // Cancel any in-flight sync/stream + streamingJob?.cancel() + streamingJob = viewModelScope.launch { + Log.e("MainViewModel","Names to start $selected") + val configsToStart=repository.getAvailableSensors().filter { selected.contains(it.name) } + Log.e("MainViewModel","Cached configs: ${repository.getAvailableSensors().map { it.name }}") + Log.e("MainViewModel","Configs to start ${configsToStart.map { it.name }}") + try { + serviceClient.bindAndStart(configsToStart) + } + catch(_:TimeoutCancellationException){ + _uiState.value=UiState.Error("Sensor initialisation timed out!") + } + _uiState.value = UiState.Streaming + } + } + + /** Cancels sync/stream and stops the service */ + fun stopStreaming() { + _uiState.value=UiState.Stopping + streamingJob?.cancel() + streamingJob = null + serviceClient.stop() + serviceClient.unbind() + _uiState.value = UiState.Idle + } + + /** + * Handle LSLService events + */ + fun onServiceEvent(event: ServiceEvent) { + when (event) { + ServiceEvent.Started -> _uiState.value = UiState.Streaming + ServiceEvent.Stopped -> _uiState.value = UiState.Idle + is ServiceEvent.Failed -> _uiState.value = UiState.Error(event.error) + else -> true + } + } + + fun clearError(){ + _uiState.value = UiState.Idle + } +} diff --git a/app/src/main/java/de/uol/neuropsy/senda/ui/state/UiState.kt b/app/src/main/java/de/uol/neuropsy/senda/ui/state/UiState.kt new file mode 100644 index 00000000..f418bfd3 --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/ui/state/UiState.kt @@ -0,0 +1,17 @@ +// UiState.kt +package de.uol.neuropsy.senda.ui.state + +import de.uol.neuropsy.senda.sensor.MovellaBridge + +sealed class UiState { + object Idle : UiState() + object Scanning : UiState() + data class DevicesDiscovered( + val sensorNames: List + ) : UiState() + data class Syncing(val progress: Int) : UiState() + object Streaming : UiState() + object Starting : UiState() + object Stopping : UiState() + data class Error(val message: String) : UiState() +} \ No newline at end of file diff --git a/app/src/main/java/de/uol/neuropsy/senda/ui/tutorial/TutorialActivity.kt b/app/src/main/java/de/uol/neuropsy/senda/ui/tutorial/TutorialActivity.kt new file mode 100644 index 00000000..3ee76a1a --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/ui/tutorial/TutorialActivity.kt @@ -0,0 +1,64 @@ +package de.uol.neuropsy.senda.ui.tutorial + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.appbar.MaterialToolbar +import de.uol.neuropsy.senda.R +/** + * The number of pages (wizard steps) to show in this demo. + */ +private const val NUM_PAGES = 5 + + + +class TutorialActivity : AppCompatActivity() { + private lateinit var viewPager: ViewPager2 + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_tutorial) + val toolbar: MaterialToolbar = findViewById(R.id.tutorial_toolbar) + // Set it as the support ActionBar + setSupportActionBar(toolbar) + // Instantiate a ViewPager2 and a PagerAdapter. + viewPager = findViewById(R.id.pager) + // The pager adapter, which provides the pages to the view pager widget. + val pagerAdapter = ScreenSlidePagerAdapter(this) + viewPager.adapter = pagerAdapter + } + + /** + * A simple pager adapter that represents ScreenSlidePageFragment objects, in + * sequence. + */ + private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { + override fun getItemCount(): Int = NUM_PAGES + override fun createFragment(position: Int): Fragment { + return TutorialPageFragment.newInstance(position) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater: MenuInflater = menuInflater + inflater.inflate(R.menu.menu_tutorial, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.close_tutorial -> { + finish() + true + } + else -> false + } + } +} + diff --git a/app/src/main/java/de/uol/neuropsy/senda/ui/tutorial/TutorialPageFragment.kt b/app/src/main/java/de/uol/neuropsy/senda/ui/tutorial/TutorialPageFragment.kt new file mode 100644 index 00000000..2120e5f0 --- /dev/null +++ b/app/src/main/java/de/uol/neuropsy/senda/ui/tutorial/TutorialPageFragment.kt @@ -0,0 +1,54 @@ +package de.uol.neuropsy.senda.ui.tutorial + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.fragment.app.Fragment +import de.uol.neuropsy.senda.R + +class TutorialPageFragment : Fragment() { + + companion object { + // Factory method that creates a new instance of TutorialPageFragment with position as an argument. + fun newInstance(position: Int): TutorialPageFragment { + val fragment = TutorialPageFragment() + val args = Bundle() + args.putInt("position", position) + fragment.arguments = args + return fragment + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Choose a layout based on the page position. While our layouts are very similar, + // this enables us to use totally different layouts for each tutorial page. + val layoutResId = when (arguments?.getInt("position") ?: 0) { + 0 -> R.layout.tutorial_page_one // For example, a layout for the first tutorial page. + 1 -> R.layout.tutorial_page_two + 2 -> R.layout.tutorial_page_three + 3 -> R.layout.tutorial_page_four + 4 -> R.layout.tutorial_end + else -> R.layout.tutorial_page_default // A default layout. + } + + val view = inflater.inflate(layoutResId, container, false) + + view.findViewById