From 6b04d4b16422eb2bbab13e2f27b37541ef8c8523 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Wed, 19 Aug 2026 23:47:18 +0200 Subject: [PATCH] refactor(sensorservices): use recording engine --- core/build.gradle.kts | 2 + sensorservices/build.gradle.kts | 2 +- .../sensorservices/handlers/GPSHandler.kt | 18 +- .../sensorservices/handlers/StorageHandler.kt | 42 +-- .../ActivityRecognitionMeasurement.kt | 101 +++---- .../handlers/measurements/ExtraInfoHandler.kt | 15 +- .../handlers/measurements/GPSMeasurement.kt | 64 ++--- .../measurements/SensorMeasurement.kt | 74 ++--- .../measurements/SignificantMotion.kt | 52 ++-- .../intent/MeasurementIntentFactory.kt | 2 +- .../intent/MeasurementLaunchRequest.kt | 2 +- .../AndroidRecordingSources.kt | 176 ++++++++++++ .../serviceController/MeasurementConfig.kt | 4 +- .../serviceController/ServiceController.kt | 158 +++-------- .../services/MeasurementService.kt | 263 ++++++++++-------- .../session/MeasurementSessionState.kt | 1 + .../session/MeasurementSessionStore.kt | 26 ++ .../sensorservices/types/SensorHolder.kt | 20 +- .../types/SensorHolderErrorTest.kt | 5 +- 19 files changed, 609 insertions(+), 418 deletions(-) create mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 16d4218..74da485 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -23,6 +23,8 @@ android { } dependencies { + api(project(":core-common")) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.documentfile) implementation(libs.coroutines.core) diff --git a/sensorservices/build.gradle.kts b/sensorservices/build.gradle.kts index e6114af..234aac3 100644 --- a/sensorservices/build.gradle.kts +++ b/sensorservices/build.gradle.kts @@ -37,7 +37,7 @@ android { dependencies { implementation(project(":core")) - implementation(project(":wearoslib")) + implementation(project(":recording-core")) implementation(libs.androidx.core.ktx) implementation(libs.play.services.location) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt index a42ac7f..930c1e2 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt @@ -14,6 +14,8 @@ import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap @@ -62,12 +64,12 @@ class GPSHandler : LocationCallback() { callback?.onLastLocationSuccess(location) } }.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Read last GPS location", error) + AppError.from(AppErrorCode.MEASUREMENT, "Read last GPS location", error) callback?.onLastLocationSuccess(null) } locationClient.requestLocationUpdates(request, this, Looper.getMainLooper()).addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Request GPS updates", error) + AppError.from(AppErrorCode.MEASUREMENT, "Request GPS updates", error) } registered = true } @@ -95,14 +97,14 @@ class GPSHandler : LocationCallback() { } /** Stops location updates for the active measurement. */ - fun gpsOff(): Result = appResult(AppError.Kind.MEASUREMENT, "Stop GPS updates") { + fun gpsOff(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Stop GPS updates") { if (registered) { Log.i(tag, "Logging off location") locationClient.flushLocations().addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Flush GPS updates", error) + AppError.from(AppErrorCode.MEASUREMENT, "Flush GPS updates", error) } locationClient.removeLocationUpdates(this).addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Remove GPS updates", error) + AppError.from(AppErrorCode.MEASUREMENT, "Remove GPS updates", error) } } registered = false @@ -133,9 +135,9 @@ class GPSHandler : LocationCallback() { * @param gpsCallback - this object will get access to location and updates, previous is forgotten * */ - fun addCallback(context: Context, gpsCallback: OnLocationChangedCallback): Result = - (if (registered) gpsOff() else Result.success(Unit)).flatMap { - appResult(AppError.Kind.MEASUREMENT, "Register GPS callback") { + fun addCallback(context: Context, gpsCallback: OnLocationChangedCallback): AppResult = + (if (registered) gpsOff() else AppResult.success(Unit)).flatMap { + appResult(AppErrorCode.MEASUREMENT, "Register GPS callback") { callback = gpsCallback initialize(context) gpsCallback.onLocationChanged(lastLocation) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt index 7428763..b63fdbc 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt @@ -3,6 +3,8 @@ package com.motionapps.sensorservices.handlers import android.content.Context import android.content.Intent import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.storage.NativeDocumentStorage @@ -21,15 +23,15 @@ object StorageHandler { return SimpleDateFormat(format, Locale.getDefault()).format(calendar.time) } - fun createMainFolder(context: Context, intent: Intent?): Result { + fun createMainFolder(context: Context, intent: Intent?): AppResult { val directoryName = context.getString(R.string.app_name) return if (intent == null) { NativeDocumentStorage.hasAppDirectory(context, directoryName).flatMap { exists -> if (exists) { - Result.success(Unit) + AppResult.success(Unit) } else { - Result.failure( - AppError(AppError.Kind.STORAGE, "Storage directory is not configured"), + AppResult.failure( + AppError(AppErrorCode.STORAGE, "Storage directory is not configured"), ) } } @@ -38,34 +40,34 @@ object StorageHandler { } } - fun isFolder(context: Context): Result = NativeDocumentStorage.hasAppDirectory( + fun isFolder(context: Context): AppResult = NativeDocumentStorage.hasAppDirectory( context = context, appDirectoryName = context.getString(R.string.app_name), ) - fun isAccess(context: Context): Result = isFolder(context) + fun isAccess(context: Context): AppResult = isFolder(context) - fun getFolderName(context: Context): Result = NativeDocumentStorage.displayPath( + fun getFolderName(context: Context): AppResult = NativeDocumentStorage.displayPath( context = context, appDirectoryName = context.getString(R.string.app_name), ).map { it ?: context.getString(R.string.no_path) } - fun createInternalStorageMeasurementFolder(context: Context, folderName: String): Result { + fun createInternalStorageMeasurementFolder(context: Context, folderName: String): AppResult { val directory = internalMeasurementDirectory(context, folderName) - return appResult(AppError.Kind.STORAGE, "Create internal measurement directory") { + return appResult(AppErrorCode.STORAGE, "Create internal measurement directory") { directory.exists() || directory.mkdirs() }.flatMap { created -> if (created) { - Result.success(Unit) + AppResult.success(Unit) } else { - Result.failure( - AppError(AppError.Kind.STORAGE, "Create internal measurement directory"), + AppResult.failure( + AppError(AppErrorCode.STORAGE, "Create internal measurement directory"), ) } } } - fun createFolderMeasurement(context: Context, folderName: String): Result = + fun createFolderMeasurement(context: Context, folderName: String): AppResult = NativeDocumentStorage.createMeasurementDirectory( context = context, appDirectoryName = context.getString(R.string.app_name), @@ -77,7 +79,7 @@ object StorageHandler { folderName: String, mimeOfNewFile: String, nameOfNewFile: String, - ): Result = NativeDocumentStorage.openMeasurementFile( + ): AppResult = NativeDocumentStorage.openMeasurementFile( context = context, appDirectoryName = context.getString(R.string.app_name), measurementName = folderName, @@ -85,23 +87,23 @@ object StorageHandler { fileName = nameOfNewFile, ) - fun createFileInInternalFolder(context: Context, folderName: String, nameOfFile: String): Result = - appResult(AppError.Kind.STORAGE, "Prepare internal measurement directory") { + fun createFileInInternalFolder(context: Context, folderName: String, nameOfFile: String): AppResult = + appResult(AppErrorCode.STORAGE, "Prepare internal measurement directory") { val directory = internalMeasurementDirectory(context, folderName) directory to (directory.exists() || directory.mkdirs()) }.flatMap { (directory, ready) -> if (!ready) { - Result.failure( - AppError(AppError.Kind.STORAGE, "Create internal measurement directory"), + AppResult.failure( + AppError(AppErrorCode.STORAGE, "Create internal measurement directory"), ) } else { - appResult(AppError.Kind.STORAGE, "Open internal measurement file") { + appResult(AppErrorCode.STORAGE, "Open internal measurement file") { FileOutputStream(File(directory, nameOfFile)) } } } - fun deleteByNameOfFolder(context: Context, deleteFolder: String): Result = + fun deleteByNameOfFolder(context: Context, deleteFolder: String): AppResult = NativeDocumentStorage.deleteMeasurement( context = context, appDirectoryName = context.getString(R.string.app_name), diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt index 5995c6f..acee0df 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt @@ -1,6 +1,7 @@ package com.motionapps.sensorservices.handlers.measurements import android.Manifest +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context @@ -8,7 +9,6 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build -import android.os.Bundle import androidx.core.content.ContextCompat import com.google.android.gms.location.ActivityRecognition import com.google.android.gms.location.ActivityRecognitionClient @@ -18,6 +18,8 @@ import com.google.android.gms.location.ActivityTransitionRequest import com.google.android.gms.location.ActivityTransitionResult import com.google.android.gms.location.DetectedActivity import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap @@ -26,7 +28,7 @@ import com.motionapps.sensorservices.handlers.StorageHandler import java.io.OutputStream /** Records periodic Google Activity Recognition confidence values. */ -class ActivityRecognitionMeasurement(private val periodSeconds: Int) : MeasurementInterface { +class ActivityRecognitionMeasurement(private val periodSeconds: Int) { private var client: ActivityRecognitionClient? = null private var updatesPendingIntent: PendingIntent? = null private var transitionsPendingIntent: PendingIntent? = null @@ -64,32 +66,30 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) : Measureme } } - override fun initMeasurement(context: Context, params: Bundle): Result { - val folder = params.getString(MeasurementInterface.FOLDER_NAME).orEmpty() - val internal = params.getBoolean(MeasurementInterface.INTERNAL_STORAGE) - val updatesResult = if (internal) { - StorageHandler.createFileInInternalFolder(context, folder, UPDATES_FILE_NAME) + fun prepare(context: Context, folderName: String, useInternalStorage: Boolean): AppResult { + val updatesResult = if (useInternalStorage) { + StorageHandler.createFileInInternalFolder(context, folderName, UPDATES_FILE_NAME) } else { - StorageHandler.createFileInFolder(context, folder, "text/csv", UPDATES_FILE_NAME) + StorageHandler.createFileInFolder(context, folderName, "text/csv", UPDATES_FILE_NAME) } return updatesResult.flatMap { updates -> updatesOutput = updates - val transitionsResult = if (internal) { - StorageHandler.createFileInInternalFolder(context, folder, TRANSITIONS_FILE_NAME) + val transitionsResult = if (useInternalStorage) { + StorageHandler.createFileInInternalFolder(context, folderName, TRANSITIONS_FILE_NAME) } else { - StorageHandler.createFileInFolder(context, folder, "text/csv", TRANSITIONS_FILE_NAME) + StorageHandler.createFileInFolder(context, folderName, "text/csv", TRANSITIONS_FILE_NAME) } transitionsResult.onFailure { - appResult(AppError.Kind.STORAGE, "Close incomplete activity measurement") { updates.close() } + appResult(AppErrorCode.STORAGE, "Close incomplete activity measurement") { updates.close() } }.flatMap { transitions -> transitionsOutput = transitions initializeResources(context) } - }.withAppError(AppError.Kind.MEASUREMENT, "Initialize activity recognition") + }.withAppError(AppErrorCode.MEASUREMENT, "Initialize activity recognition") } - private fun initializeResources(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + private fun initializeResources(context: Context): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Initialize activity recognition resources", ) { updatesOutput?.write( @@ -97,11 +97,7 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) : Measureme ) transitionsOutput?.write("t_nanos;activity;enter_exit\n".toByteArray()) val filter = IntentFilter(ACTION_UPDATE).apply { addAction(ACTION_TRANSITION) } - if (Build.VERSION.SDK_INT >= 33) { - context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.registerReceiver(receiver, filter) - } + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) receiverRegistered = true client = ActivityRecognition.getClient(context) updatesPendingIntent = PendingIntent.getBroadcast( @@ -118,43 +114,42 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) : Measureme ) } - override fun startMeasurement(context: Context): Result { - if (Build.VERSION.SDK_INT >= 29 && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACTIVITY_RECOGNITION, - ) != PackageManager.PERMISSION_GRANTED - ) { - return Result.failure(AppError(AppError.Kind.PERMISSION, "Start activity recognition")) + @SuppressLint("MissingPermission") + fun start(context: Context): AppResult { + if (!hasPermission(context)) { + return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) } - return appResult(AppError.Kind.MEASUREMENT, "Start activity recognition") { + return appResult(AppErrorCode.MEASUREMENT, "Start activity recognition") { updatesPendingIntent?.let { client?.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, it) ?.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Request activity updates", error) + AppError.from(AppErrorCode.MEASUREMENT, "Request activity updates", error) } } transitionsPendingIntent?.let { client?.requestActivityTransitionUpdates(ActivityTransitionRequest(ACTIVITY_TRANSITIONS), it) ?.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Request activity transitions", error) + AppError.from(AppErrorCode.MEASUREMENT, "Request activity transitions", error) } } } } - override fun pauseMeasurement(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + @SuppressLint("MissingPermission") + private fun pause(context: Context): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Pause activity recognition", ) { - updatesPendingIntent?.let { - client?.removeActivityUpdates(it)?.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Remove activity updates", error) + if (hasPermission(context)) { + updatesPendingIntent?.let { + client?.removeActivityUpdates(it)?.addOnFailureListener { error -> + AppError.from(AppErrorCode.MEASUREMENT, "Remove activity updates", error) + } } - } - transitionsPendingIntent?.let { - client?.removeActivityTransitionUpdates(it)?.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Remove activity transitions", error) + transitionsPendingIntent?.let { + client?.removeActivityTransitionUpdates(it)?.addOnFailureListener { error -> + AppError.from(AppErrorCode.MEASUREMENT, "Remove activity transitions", error) + } } } if (receiverRegistered) { @@ -163,34 +158,40 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) : Measureme receiverRegistered = false } - override suspend fun saveMeasurement(context: Context): Result { - val results = mutableListOf>() - results += appResult(AppError.Kind.STORAGE, "Close activity updates") { + private suspend fun save(): AppResult { + val results = mutableListOf>() + results += appResult(AppErrorCode.STORAGE, "Close activity updates") { updatesOutput?.close() } - results += appResult(AppError.Kind.STORAGE, "Close activity transitions") { + results += appResult(AppErrorCode.STORAGE, "Close activity transitions") { transitionsOutput?.close() } - writeFailure?.let { results += Result.failure(it) } + writeFailure?.let { results += AppResult.failure(it) } updatesOutput = null transitionsOutput = null writeFailure = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Save activity recognition") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save activity recognition") } - override suspend fun onDestroyMeasurement(context: Context): Result { - val results = listOf(pauseMeasurement(context), saveMeasurement(context)) + suspend fun stop(context: Context): AppResult { + val results = listOf(pause(context), save()) updatesPendingIntent = null transitionsPendingIntent = null client = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Stop activity recognition") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop activity recognition") } private inline fun recordWriteFailure(operation: String, block: () -> Unit) { if (writeFailure != null) return - appResult(AppError.Kind.STORAGE, operation, block).onFailure { writeFailure = it as AppError } + appResult(AppErrorCode.STORAGE, operation, block).onFailure { writeFailure = it } } + private fun hasPermission(context: Context): Boolean = Build.VERSION.SDK_INT < 29 || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACTIVITY_RECOGNITION, + ) == PackageManager.PERMISSION_GRANTED + private companion object { const val ACTION_UPDATE = "com.motionapps.sensorbox.ACTIVITY_RECOGNITION_UPDATE" const val ACTION_TRANSITION = "com.motionapps.sensorbox.ACTIVITY_RECOGNITION_TRANSITION" diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt index c16d73d..d2c3fa5 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt @@ -4,7 +4,8 @@ import android.content.Context import android.hardware.Sensor import android.hardware.SensorManager import android.os.SystemClock -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.withAppError @@ -39,11 +40,11 @@ class ExtraInfoHandler { triggeredAlarms += timestampMillis } - fun write(context: Context): Result { - val active = config ?: return Result.success(Unit) - if (written) return Result.success(Unit) + fun write(context: Context): AppResult { + val active = config ?: return AppResult.success(Unit) + if (written) return AppResult.success(Unit) written = true - val jsonResult = appResult(AppError.Kind.MEASUREMENT, "Build measurement metadata") { + val jsonResult = appResult(AppErrorCode.MEASUREMENT, "Build measurement metadata") { JSONObject().apply { put("millis", startedAtMillis) put("nanos", startedAtNanos) @@ -83,11 +84,11 @@ class ExtraInfoHandler { StorageHandler.createFileInFolder(context, active.folderName, "application/json", EXTRA_FILE) } stream.flatMap { output -> - appResult(AppError.Kind.STORAGE, "Write measurement metadata file") { + appResult(AppErrorCode.STORAGE, "Write measurement metadata file") { output.use { it.write(json.toString(2).toByteArray()) } } } - }.withAppError(AppError.Kind.MEASUREMENT, "Write measurement metadata") + }.withAppError(AppErrorCode.MEASUREMENT, "Write measurement metadata") } private fun sensorRanges(context: Context, sensorIds: IntArray): JSONArray { diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt index b3dbc91..a073eb4 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt @@ -2,18 +2,16 @@ package com.motionapps.sensorservices.handlers.measurements import android.content.Context import android.location.Location -import android.os.Bundle import com.google.android.gms.location.LocationAvailability import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.withAppError import com.motionapps.sensorservices.handlers.GPSHandler import com.motionapps.sensorservices.handlers.StorageHandler -import com.motionapps.sensorservices.handlers.measurements.MeasurementInterface.Companion.FOLDER_NAME -import com.motionapps.sensorservices.handlers.measurements.MeasurementInterface.Companion.INTERNAL_STORAGE -import com.motionapps.sensorservices.services.MeasurementService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.OutputStream @@ -23,9 +21,7 @@ import java.io.OutputStream * * @property gpsHandler - manages access to GPS */ -class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : - MeasurementInterface, - GPSHandler.OnLocationChangedCallback { +class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : GPSHandler.OnLocationChangedCallback { private var outputStream: OutputStream? = null private var writeFailure: AppError? = null @@ -37,29 +33,35 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : * @param context * @param params - from the service */ - override fun initMeasurement(context: Context, params: Bundle): Result { + fun prepare( + context: Context, + folderName: String, + useInternalStorage: Boolean, + intervalSeconds: Int, + minimumDistanceMeters: Int, + ): AppResult { gpsHandler.configure( - intervalSeconds = params.getInt(MeasurementService.GPS_INTERVAL_SECONDS, 10), - minDistanceMeters = params.getInt(MeasurementService.GPS_DISTANCE_METERS, 20), + intervalSeconds = intervalSeconds, + minDistanceMeters = minimumDistanceMeters, ) - val stream = if (params.getBoolean(INTERNAL_STORAGE)) { + val stream = if (useInternalStorage) { StorageHandler.createFileInInternalFolder( context, - params.getString(FOLDER_NAME).orEmpty(), + folderName, "gps.csv", ) } else { StorageHandler.createFileInFolder( context, - params.getString(FOLDER_NAME).orEmpty(), + folderName, "csv", "gps.csv", ) } return stream.flatMap { output -> outputStream = output - appResult(AppError.Kind.STORAGE, "Write GPS header") { output.write(header.toByteArray()) } - }.withAppError(AppError.Kind.MEASUREMENT, "Initialize GPS measurement") + appResult(AppErrorCode.STORAGE, "Write GPS header") { output.write(header.toByteArray()) } + }.withAppError(AppErrorCode.MEASUREMENT, "Initialize GPS measurement") } /** @@ -83,31 +85,31 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : * @param context */ - override fun startMeasurement(context: Context): Result = gpsHandler.addCallback(context, this) - .withAppError(AppError.Kind.MEASUREMENT, "Start GPS") + fun start(context: Context): AppResult = gpsHandler.addCallback(context, this) + .withAppError(AppErrorCode.MEASUREMENT, "Start GPS") /** * turns off the GPS * * @param context */ - override fun pauseMeasurement(context: Context): Result = gpsHandler.gpsOff() - .withAppError(AppError.Kind.MEASUREMENT, "Pause GPS") + private fun pause(): AppResult = gpsHandler.gpsOff() + .withAppError(AppErrorCode.MEASUREMENT, "Pause GPS") /** * outputStream is saved and closed * * @param context */ - override suspend fun saveMeasurement(context: Context): Result { + private suspend fun save(): AppResult { val stream = outputStream - val results = mutableListOf>() - results += appResult(AppError.Kind.STORAGE, "Flush GPS measurement") { stream?.flush() } - writeFailure?.let { results += Result.failure(it) } - results += appResult(AppError.Kind.STORAGE, "Close GPS measurement") { stream?.close() } + val results = mutableListOf>() + results += appResult(AppErrorCode.STORAGE, "Flush GPS measurement") { stream?.flush() } + writeFailure?.let { results += AppResult.failure(it) } + results += appResult(AppErrorCode.STORAGE, "Close GPS measurement") { stream?.close() } outputStream = null writeFailure = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Save GPS measurement") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save GPS measurement") } /** @@ -115,10 +117,10 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : * * @param context */ - override suspend fun onDestroyMeasurement(context: Context): Result = listOf( - withContext(Dispatchers.Main) { pauseMeasurement(context) }, - withContext(Dispatchers.IO) { saveMeasurement(context) }, - ).combineAppResults(AppError.Kind.MEASUREMENT, "Stop GPS measurement") + suspend fun stop(): AppResult = listOf( + withContext(Dispatchers.Main) { pause() }, + withContext(Dispatchers.IO) { save() }, + ).combineAppResults(AppErrorCode.MEASUREMENT, "Stop GPS measurement") /** * called on GPS change @@ -127,9 +129,9 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : */ override fun onLocationChanged(location: Location?) { location?.let { loc: Location -> - appResult(AppError.Kind.STORAGE, "Write GPS sample") { + appResult(AppErrorCode.STORAGE, "Write GPS sample") { outputStream?.write(createLocationStamp(loc).toByteArray()) - }.onFailure { writeFailure = it as AppError } + }.onFailure { writeFailure = it } } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt index 0b5a527..e465989 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt @@ -2,8 +2,9 @@ package com.motionapps.sensorservices.handlers.measurements import android.content.Context import android.hardware.SensorManager -import android.os.Bundle import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap @@ -12,37 +13,48 @@ import com.motionapps.sensorservices.handlers.StorageHandler import com.motionapps.sensorservices.types.SensorHolder import com.motionapps.sensorservices.types.SensorSpec -class SensorMeasurement : MeasurementInterface { +class SensorMeasurement { private val holders = mutableListOf() private var samplingPeriod = SensorManager.SENSOR_DELAY_FASTEST - override fun initMeasurement(context: Context, params: Bundle): Result { - samplingPeriod = params.getInt(MeasurementInterface.SENSOR_SPEED) - return (params.getIntArray(MeasurementInterface.SENSOR_ID) ?: intArrayOf()).fold( - Result.success(Unit), + fun prepare( + context: Context, + folderName: String, + useInternalStorage: Boolean, + sensorTypes: Set, + samplingPeriod: Int, + ): AppResult { + this.samplingPeriod = samplingPeriod + return sensorTypes.sorted().fold( + AppResult.success(Unit), ) { result, sensorType -> result.flatMap { - createHolder(context, params, sensorType).map { holder -> + createHolder(context, folderName, useInternalStorage, sensorType).map { holder -> holder?.let(holders::add) Unit } } } - .withAppError(AppError.Kind.MEASUREMENT, "Initialize sensors") + .withAppError(AppErrorCode.MEASUREMENT, "Initialize sensors") } - private fun createHolder(context: Context, params: Bundle, sensorType: Int): Result { - val spec = SensorSpec.fromType(sensorType) ?: return Result.success(null) - val stream = if (params.getBoolean(MeasurementInterface.INTERNAL_STORAGE)) { + private fun createHolder( + context: Context, + folderName: String, + useInternalStorage: Boolean, + sensorType: Int, + ): AppResult { + val spec = SensorSpec.fromType(sensorType) ?: return AppResult.success(null) + val stream = if (useInternalStorage) { StorageHandler.createFileInInternalFolder( context, - params.getString(MeasurementInterface.FOLDER_NAME).orEmpty(), + folderName, spec.fileName, ) } else { StorageHandler.createFileInFolder( context, - params.getString(MeasurementInterface.FOLDER_NAME).orEmpty(), + folderName, "text/csv", spec.fileName, ) @@ -50,49 +62,49 @@ class SensorMeasurement : MeasurementInterface { return stream.map { SensorHolder(spec, it) } } - override fun startMeasurement(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + fun start(context: Context): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Access sensor manager", ) { context.getSystemService(SensorManager::class.java) }.flatMap { sensorManager -> - holders.fold(Result.success(Unit)) { result, holder -> + holders.fold(AppResult.success(Unit)) { result, holder -> result.flatMap { val sensor = sensorManager.getDefaultSensor(holder.spec.type) - ?: return@flatMap Result.failure( - AppError(AppError.Kind.MEASUREMENT, "Find sensor ${holder.spec.type}"), + ?: return@flatMap AppResult.failure( + AppError(AppErrorCode.MEASUREMENT, "Find sensor ${holder.spec.type}"), ) - appResult(AppError.Kind.MEASUREMENT, "Register sensor ${holder.spec.type}") { + appResult(AppErrorCode.MEASUREMENT, "Register sensor ${holder.spec.type}") { sensorManager.registerListener(holder, sensor, samplingPeriod) }.flatMap { registered -> if (registered) { - Result.success(Unit) + AppResult.success(Unit) } else { - Result.failure( - AppError(AppError.Kind.MEASUREMENT, "Register sensor ${holder.spec.type}"), + AppResult.failure( + AppError(AppErrorCode.MEASUREMENT, "Register sensor ${holder.spec.type}"), ) } } } } - }.withAppError(AppError.Kind.MEASUREMENT, "Start sensors") + }.withAppError(AppErrorCode.MEASUREMENT, "Start sensors") - override fun pauseMeasurement(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + private fun pause(context: Context): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Pause sensors", ) { val sensorManager = context.getSystemService(SensorManager::class.java) holders.forEach(sensorManager::unregisterListener) } - override suspend fun saveMeasurement(context: Context): Result { + private suspend fun save(): AppResult { val results = holders.map { it.close() } holders.clear() - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Save sensors") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save sensors") } - override suspend fun onDestroyMeasurement(context: Context): Result = listOf( - pauseMeasurement(context), - saveMeasurement(context), - ).combineAppResults(AppError.Kind.MEASUREMENT, "Stop sensors") + suspend fun stop(context: Context): AppResult = listOf( + pause(context), + save(), + ).combineAppResults(AppErrorCode.MEASUREMENT, "Stop sensors") } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt index ad827cb..220a4a7 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt @@ -5,8 +5,9 @@ import android.hardware.Sensor import android.hardware.SensorManager import android.hardware.TriggerEvent import android.hardware.TriggerEventListener -import android.os.Bundle import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap @@ -15,67 +16,64 @@ import com.motionapps.sensorservices.handlers.StorageHandler import java.io.OutputStream /** Handles Android's one-shot significant-motion trigger and re-arms it after every event. */ -class SignificantMotion : - TriggerEventListener(), - MeasurementInterface { +class SignificantMotion : TriggerEventListener() { private var sensorManager: SensorManager? = null private var sensor: Sensor? = null private var output: OutputStream? = null private var writeFailure: AppError? = null - override fun initMeasurement(context: Context, params: Bundle): Result { + fun prepare(context: Context, folderName: String, useInternalStorage: Boolean): AppResult { sensorManager = context.getSystemService(SensorManager::class.java) sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) - val folder = params.getString(MeasurementInterface.FOLDER_NAME).orEmpty() - val stream = if (params.getBoolean(MeasurementInterface.INTERNAL_STORAGE)) { - StorageHandler.createFileInInternalFolder(context, folder, FILE_NAME) + val stream = if (useInternalStorage) { + StorageHandler.createFileInInternalFolder(context, folderName, FILE_NAME) } else { - StorageHandler.createFileInFolder(context, folder, "text/csv", FILE_NAME) + StorageHandler.createFileInFolder(context, folderName, "text/csv", FILE_NAME) } return stream.flatMap { opened -> output = opened - appResult(AppError.Kind.STORAGE, "Write significant motion header") { + appResult(AppErrorCode.STORAGE, "Write significant motion header") { opened.write("t_unix;event\n".toByteArray()) } - }.withAppError(AppError.Kind.MEASUREMENT, "Initialize significant motion") + }.withAppError(AppErrorCode.MEASUREMENT, "Initialize significant motion") } - override fun startMeasurement(context: Context): Result = if (arm()) { - Result.success(Unit) + fun start(): AppResult = if (arm()) { + AppResult.success(Unit) } else { - Result.failure(AppError(AppError.Kind.MEASUREMENT, "Start significant motion")) + AppResult.failure(AppError(AppErrorCode.MEASUREMENT, "Start significant motion")) } - override fun pauseMeasurement(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + private fun pause(): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Pause significant motion", ) { sensor?.let { sensorManager?.cancelTriggerSensor(this, it) } } - override suspend fun saveMeasurement(context: Context): Result { - val results = mutableListOf>() - results += appResult(AppError.Kind.STORAGE, "Close significant motion") { output?.close() } - writeFailure?.let { results += Result.failure(it) } + private suspend fun save(): AppResult { + val results = mutableListOf>() + results += appResult(AppErrorCode.STORAGE, "Close significant motion") { output?.close() } + writeFailure?.let { results += AppResult.failure(it) } output = null writeFailure = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Save significant motion") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save significant motion") } - override suspend fun onDestroyMeasurement(context: Context): Result { - val results = listOf(pauseMeasurement(context), saveMeasurement(context)) + suspend fun stop(): AppResult { + val results = listOf(pause(), save()) sensor = null sensorManager = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Stop significant motion") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop significant motion") } override fun onTrigger(event: TriggerEvent?) { event?.values?.firstOrNull()?.let { value -> - appResult(AppError.Kind.STORAGE, "Write significant motion") { + appResult(AppErrorCode.STORAGE, "Write significant motion") { output?.write("${System.currentTimeMillis()};$value\n".toByteArray()) - }.onFailure { writeFailure = it as AppError } + }.onFailure { writeFailure = it } } - if (!arm()) AppError(AppError.Kind.MEASUREMENT, "Re-arm significant motion") + if (!arm()) AppError(AppErrorCode.MEASUREMENT, "Re-arm significant motion") } private fun arm(): Boolean = sensor?.let { sensorManager?.requestTriggerSensor(this, it) } == true diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt index f110b51..1f9ab0d 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt @@ -9,6 +9,7 @@ import javax.inject.Inject class MeasurementIntentFactory @Inject constructor(@ApplicationContext private val context: Context) { fun create(request: MeasurementLaunchRequest): Intent = Intent(context, MeasurementService::class.java).apply { + putExtra(MeasurementService.SESSION_ID, request.sessionId) putExtra(MeasurementService.FOLDER_NAME, request.folderName) putExtra(MeasurementService.INTERNAL_STORAGE, request.useInternalStorage) putExtra(MeasurementService.ANDROID_SENSORS, request.sensorIds.toIntArray()) @@ -26,7 +27,6 @@ class MeasurementIntentFactory @Inject constructor(@ApplicationContext private v putExtra(MeasurementService.ACTIVITY_RECOGNITION, request.activityRecognition) putExtra(MeasurementService.ACTIVITY_RECOGNITION_PERIOD_SECONDS, request.activityRecognitionPeriodSeconds) putExtra(MeasurementService.SIGNIFICANT_MOTION, request.significantMotion) - putExtra(MeasurementService.CONTROLS_WEAR_MEASUREMENT, request.controlsWearMeasurement) } fun newFolderName(customName: String = "", measurementType: String = "ENDLESS"): String { diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt index 638bdde..73c9115 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt @@ -1,6 +1,7 @@ package com.motionapps.sensorservices.intent data class MeasurementLaunchRequest( + val sessionId: String = java.util.UUID.randomUUID().toString(), val folderName: String, val useInternalStorage: Boolean, val sensorIds: Set, @@ -18,5 +19,4 @@ data class MeasurementLaunchRequest( val activityRecognition: Boolean = false, val activityRecognitionPeriodSeconds: Int = 30, val significantMotion: Boolean = false, - val controlsWearMeasurement: Boolean = false, ) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt new file mode 100644 index 0000000..6d55c38 --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt @@ -0,0 +1,176 @@ +package com.motionapps.sensorservices.serviceController + +import android.content.Context +import android.media.AudioManager +import android.media.ToneGenerator +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.core.error.combineAppResults +import com.motionapps.sensorbox.recording.RecordingSource +import com.motionapps.sensorbox.recording.RecordingSourceSpec +import com.motionapps.sensorbox.recording.RecordingSourceType +import com.motionapps.sensorservices.handlers.GPSHandler +import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorservices.handlers.measurements.ActivityRecognitionMeasurement +import com.motionapps.sensorservices.handlers.measurements.ExtraInfoHandler +import com.motionapps.sensorservices.handlers.measurements.GPSMeasurement +import com.motionapps.sensorservices.handlers.measurements.SensorMeasurement +import com.motionapps.sensorservices.handlers.measurements.SignificantMotion + +class AndroidRecordingSources(private val context: Context, private val config: MeasurementConfig) { + private val artifacts = SessionArtifacts(context, config) + + val sources: List = listOf( + SessionSource(artifacts), + SensorSource(context, config), + GpsSource(context, config), + ActivitySource(context, config), + SignificantMotionSource(context, config), + ) + + fun annotate(timestampMillis: Long, text: String): AppResult = artifacts.annotate(timestampMillis, text) + + fun playAlarm(): AppResult = artifacts.playAlarm() +} + +private class SessionSource(private val artifacts: SessionArtifacts) : RecordingSource { + override val type = RecordingSourceType.SESSION + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.Session) artifacts.prepare() else invalidSpec(type) + + override suspend fun start(): AppResult = AppResult.success(Unit) + + override suspend fun stop(): AppResult = artifacts.stop() +} + +private class SensorSource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { + private val measurement = SensorMeasurement() + override val type = RecordingSourceType.SENSOR + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.Sensors) { + measurement.prepare( + context = context, + folderName = config.folderName, + useInternalStorage = config.useInternalStorage, + sensorTypes = spec.sensorTypes, + samplingPeriod = spec.samplingPeriod, + ) + } else { + invalidSpec(type) + } + + override suspend fun start(): AppResult = measurement.start(context) + + override suspend fun stop(): AppResult = measurement.stop(context) +} + +private class GpsSource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { + private val measurement = GPSMeasurement(GPSHandler()) + override val type = RecordingSourceType.GPS + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Gps) { + measurement.prepare( + context = context, + folderName = config.folderName, + useInternalStorage = config.useInternalStorage, + intervalSeconds = spec.intervalSeconds, + minimumDistanceMeters = spec.minimumDistanceMeters, + ) + } else { + invalidSpec(type) + } + + override suspend fun start(): AppResult = measurement.start(context) + + override suspend fun stop(): AppResult = measurement.stop() +} + +private class ActivitySource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { + private var measurement: ActivityRecognitionMeasurement? = null + override val type = RecordingSourceType.ACTIVITY_RECOGNITION + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.ActivityRecognition) { + ActivityRecognitionMeasurement(spec.periodSeconds).also { measurement = it }.prepare( + context, + config.folderName, + config.useInternalStorage, + ) + } else { + invalidSpec(type) + } + + override suspend fun start(): AppResult = measurement?.start(context) + ?: invalidSpec(type) + + override suspend fun stop(): AppResult { + val result = measurement?.stop(context) ?: AppResult.success(Unit) + measurement = null + return result + } +} + +private class SignificantMotionSource(private val context: Context, private val config: MeasurementConfig) : + RecordingSource { + private val measurement = SignificantMotion() + override val type = RecordingSourceType.SIGNIFICANT_MOTION + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.SignificantMotion) { + measurement.prepare(context, config.folderName, config.useInternalStorage) + } else { + invalidSpec(type) + } + + override suspend fun start(): AppResult = measurement.start() + + override suspend fun stop(): AppResult = measurement.stop() +} + +private class SessionArtifacts(private val context: Context, private val config: MeasurementConfig) { + private val extraInfo = ExtraInfoHandler() + private var toneGenerator: ToneGenerator? = null + + fun prepare(): AppResult { + val directory = if (config.useInternalStorage) { + StorageHandler.createInternalStorageMeasurementFolder(context, config.folderName) + } else { + StorageHandler.createFolderMeasurement(context, config.folderName) + } + return directory.onSuccess { extraInfo.start(config) } + } + + fun annotate(timestampMillis: Long, text: String): AppResult = appResult( + AppErrorCode.MEASUREMENT, + "Add measurement annotation", + ) { + extraInfo.annotate(timestampMillis, text) + } + + fun playAlarm(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Play measurement alarm") { + extraInfo.alarmTriggered() + val tone = toneGenerator ?: ToneGenerator(AudioManager.STREAM_ALARM, 100).also { toneGenerator = it } + tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, ALARM_DURATION_MILLIS) + } + + fun stop(): AppResult { + val releaseTone = appResult(AppErrorCode.MEASUREMENT, "Release measurement alarm") { + toneGenerator?.release() + toneGenerator = null + } + return listOf(releaseTone, extraInfo.write(context)) + .combineAppResults(AppErrorCode.MEASUREMENT, "Close measurement session artifacts") + } + + private companion object { + const val ALARM_DURATION_MILLIS = 1_000 + } +} + +private fun invalidSpec(type: RecordingSourceType): AppResult = AppResult.failure( + AppError(AppErrorCode.VALIDATION, "Prepare $type recording source"), +) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt index 61fcc48..beea66c 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt @@ -5,6 +5,7 @@ import android.hardware.SensorManager import com.motionapps.sensorservices.services.MeasurementService data class MeasurementConfig( + val sessionId: String, val folderName: String, val useInternalStorage: Boolean, val sensorIds: IntArray, @@ -22,10 +23,10 @@ data class MeasurementConfig( val activityRecognition: Boolean, val activityRecognitionPeriodSeconds: Int, val significantMotion: Boolean, - val controlsWearMeasurement: Boolean, ) { companion object { fun from(intent: Intent): MeasurementConfig = MeasurementConfig( + sessionId = intent.getStringExtra(MeasurementService.SESSION_ID).orEmpty(), folderName = intent.getStringExtra(MeasurementService.FOLDER_NAME).orEmpty(), useInternalStorage = intent.getBooleanExtra(MeasurementService.INTERNAL_STORAGE, false), sensorIds = intent.getIntArrayExtra(MeasurementService.ANDROID_SENSORS) ?: intArrayOf(), @@ -53,7 +54,6 @@ data class MeasurementConfig( 30, ).coerceAtLeast(1), significantMotion = intent.getBooleanExtra(MeasurementService.SIGNIFICANT_MOTION, false), - controlsWearMeasurement = intent.getBooleanExtra(MeasurementService.CONTROLS_WEAR_MEASUREMENT, false), ) } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt index 7032964..6cd0143 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt @@ -1,124 +1,58 @@ package com.motionapps.sensorservices.serviceController import android.content.Context -import android.media.AudioManager -import android.media.ToneGenerator -import android.os.Bundle -import com.motionapps.sensorbox.core.error.AppError -import com.motionapps.sensorbox.core.error.appResult -import com.motionapps.sensorbox.core.error.combineAppResults -import com.motionapps.sensorbox.core.error.flatMap -import com.motionapps.sensorbox.core.error.withAppError -import com.motionapps.sensorservices.handlers.GPSHandler -import com.motionapps.sensorservices.handlers.StorageHandler -import com.motionapps.sensorservices.handlers.measurements.ActivityRecognitionMeasurement -import com.motionapps.sensorservices.handlers.measurements.ExtraInfoHandler -import com.motionapps.sensorservices.handlers.measurements.GPSMeasurement -import com.motionapps.sensorservices.handlers.measurements.MeasurementInterface -import com.motionapps.sensorservices.handlers.measurements.SensorMeasurement -import com.motionapps.sensorservices.handlers.measurements.SignificantMotion -import com.motionapps.sensorservices.services.MeasurementService - -class ServiceController { - private val sensorMeasurement = SensorMeasurement() - private val gpsMeasurement = GPSMeasurement(GPSHandler()) - private var activeConfig: MeasurementConfig? = null - private val extraInfo = ExtraInfoHandler() - private var activityRecognition: ActivityRecognitionMeasurement? = null - private val significantMotion = SignificantMotion() - private var toneGenerator: ToneGenerator? = null - - fun start(context: Context, config: MeasurementConfig): Result { - var result = createMeasurementDirectory(context, config).flatMap { - appResult(AppError.Kind.MEASUREMENT, "Initialize measurement session") { - activeConfig = config - extraInfo.start(config) - } +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.recording.RecordingClock +import com.motionapps.sensorbox.recording.RecordingDelay +import com.motionapps.sensorbox.recording.RecordingEngine +import com.motionapps.sensorbox.recording.RecordingEvent +import com.motionapps.sensorbox.recording.RecordingPlan +import com.motionapps.sensorbox.recording.RecordingSessionId +import com.motionapps.sensorbox.recording.RecordingSourceSpec +import com.motionapps.sensorbox.recording.RecordingStopReason +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharedFlow + +class ServiceController(context: Context, private val config: MeasurementConfig, scope: CoroutineScope) { + private val androidSources = AndroidRecordingSources(context, config) + private val sessionId = RecordingSessionId(config.sessionId) + private val engine = RecordingEngine( + sources = androidSources.sources, + scope = scope, + clock = RecordingClock(System::currentTimeMillis), + delay = RecordingDelay { durationMillis -> delay(durationMillis) }, + ) + + val events: SharedFlow = engine.events + + suspend fun prepareAndCommit(): AppResult { + val plan = config.toRecordingPlan(sessionId) + return when (val prepared = engine.prepare(plan)) { + is AppResult.Success -> engine.commit(sessionId) + is AppResult.Failure -> prepared } - if (config.sensorIds.isNotEmpty()) result = result.flatMap { startSensors(context, config) } - if (config.includesGps) result = result.flatMap { startGps(context, config) } - if (config.activityRecognition) result = result.flatMap { startActivityRecognition(context, config) } - if (config.significantMotion) result = result.flatMap { startSignificantMotion(context, config) } - return result.withAppError(AppError.Kind.MEASUREMENT, "Start measurement controller") } - suspend fun stop(context: Context): Result { - val config = activeConfig ?: return Result.success(Unit) - val results = mutableListOf>() - if (config.sensorIds.isNotEmpty()) results += sensorMeasurement.onDestroyMeasurement(context) - if (config.includesGps) results += gpsMeasurement.onDestroyMeasurement(context) - activityRecognition?.let { results += it.onDestroyMeasurement(context) } - activityRecognition = null - if (config.significantMotion) results += significantMotion.onDestroyMeasurement(context) - results += appResult(AppError.Kind.MEASUREMENT, "Release alarm") { - toneGenerator?.release() - Unit - } - toneGenerator = null - results += extraInfo.write(context) - activeConfig = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Stop measurement controller") - } + suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(sessionId, reason) - fun annotate(timestampMillis: Long, text: String): Result = appResult( - AppError.Kind.MEASUREMENT, - "Add measurement annotation", - ) { - extraInfo.annotate(timestampMillis, text) - } - - fun playAlarm(): Result = appResult(AppError.Kind.MEASUREMENT, "Play measurement alarm") { - extraInfo.alarmTriggered() - val tone = toneGenerator ?: ToneGenerator(AudioManager.STREAM_ALARM, 100).also { toneGenerator = it } - tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, ALARM_DURATION_MILLIS) - } - - private fun createMeasurementDirectory(context: Context, config: MeasurementConfig): Result = - if (config.useInternalStorage) { - StorageHandler.createInternalStorageMeasurementFolder(context, config.folderName) - } else { - StorageHandler.createFolderMeasurement(context, config.folderName) - } + fun annotate(timestampMillis: Long, text: String): AppResult = androidSources.annotate(timestampMillis, text) - private fun startSensors(context: Context, config: MeasurementConfig): Result { - val params = baseParams(config).apply { - putIntArray(MeasurementInterface.SENSOR_ID, config.sensorIds) - putInt(MeasurementInterface.SENSOR_SPEED, config.sensorSamplingPeriod) - } - return sensorMeasurement.initMeasurement(context, params) - .flatMap { sensorMeasurement.startMeasurement(context) } - .withAppError(AppError.Kind.MEASUREMENT, "Start configured sensors") - } + fun playAlarm(): AppResult = androidSources.playAlarm() - private fun startGps(context: Context, config: MeasurementConfig): Result { - val params = baseParams(config).apply { - putInt(MeasurementService.GPS_INTERVAL_SECONDS, config.gpsIntervalSeconds) - putInt(MeasurementService.GPS_DISTANCE_METERS, config.gpsMinDistanceMeters) + private fun MeasurementConfig.toRecordingPlan(sessionId: RecordingSessionId): RecordingPlan { + val specs = buildList { + add(RecordingSourceSpec.Session) + if (sensorIds.isNotEmpty()) add(RecordingSourceSpec.Sensors(sensorIds.toSet(), sensorSamplingPeriod)) + if (includesGps) add(RecordingSourceSpec.Gps(gpsIntervalSeconds, gpsMinDistanceMeters)) + if (activityRecognition) add(RecordingSourceSpec.ActivityRecognition(activityRecognitionPeriodSeconds)) + if (significantMotion) add(RecordingSourceSpec.SignificantMotion) } - return gpsMeasurement.initMeasurement(context, params) - .flatMap { gpsMeasurement.startMeasurement(context) } - .withAppError(AppError.Kind.MEASUREMENT, "Start configured GPS") - } - - private fun startActivityRecognition(context: Context, config: MeasurementConfig): Result { - val handler = ActivityRecognitionMeasurement(config.activityRecognitionPeriodSeconds) - return handler.initMeasurement(context, baseParams(config)) - .flatMap { handler.startMeasurement(context) } - .onSuccess { activityRecognition = handler } - .withAppError(AppError.Kind.MEASUREMENT, "Start configured activity recognition") - } - - private fun startSignificantMotion(context: Context, config: MeasurementConfig): Result = - significantMotion.initMeasurement(context, baseParams(config)) - .flatMap { significantMotion.startMeasurement(context) } - .withAppError(AppError.Kind.MEASUREMENT, "Start configured significant motion") - - private fun baseParams(config: MeasurementConfig) = Bundle().apply { - putString(MeasurementInterface.FOLDER_NAME, config.folderName) - putBoolean(MeasurementInterface.INTERNAL_STORAGE, config.useInternalStorage) - } - - private companion object { - const val ALARM_DURATION_MILLIS = 1_000 + return RecordingPlan( + sessionId = sessionId, + sources = specs, + startAtEpochMillis = startAtEpochMillis, + durationMillis = durationMillis, + ) } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt index 2e288eb..7eb3a98 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt @@ -11,19 +11,17 @@ import android.os.Build import android.os.IBinder import android.os.PowerManager import android.os.SystemClock -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults -import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.sensorbox.recording.RecordingEvent +import com.motionapps.sensorbox.recording.RecordingStopReason import com.motionapps.sensorservices.serviceController.MeasurementConfig import com.motionapps.sensorservices.serviceController.ServiceController import com.motionapps.sensorservices.session.MeasurementSessionState import com.motionapps.sensorservices.session.MeasurementSessionStore -import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase -import com.motionapps.wearoslib.protocol.WearCommand -import com.motionapps.wearoslib.protocol.WearCommandCodec +import com.motionapps.sensorservices.session.MeasurementStopReason import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,6 +30,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import javax.inject.Inject @AndroidEntryPoint @@ -39,22 +38,18 @@ class MeasurementService : Service() { @Inject lateinit var sessionStore: MeasurementSessionStore - @Inject - lateinit var sendWearMessage: SendWearMessageUseCase - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val controller = ServiceController() + private var controller: ServiceController? = null private var activeConfig: MeasurementConfig? = null + private var eventJob: Job? = null private var wakeLock: PowerManager.WakeLock? = null - private var isStopping = false private var batteryReceiverRegistered = false - private var startJob: Job? = null - private var durationJob: Job? = null private var alarmJobs: List = emptyList() + private var isFinishing = false private val lowBatteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_BATTERY_LOW) stopMeasurement() + if (intent?.action == Intent.ACTION_BATTERY_LOW) requestStop(RecordingStopReason.LOW_BATTERY) } } @@ -62,55 +57,123 @@ class MeasurementService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_STOP -> stopMeasurement() + ACTION_STOP -> requestStop(RecordingStopReason.USER_REQUEST) - ACTION_ANNOTATE -> controller.annotate( + ACTION_ANNOTATE -> controller?.annotate( intent.getLongExtra(ANNOTATION_TIME, System.currentTimeMillis()), intent.getStringExtra(ANNOTATION_TEXT).orEmpty(), ) - else -> intent?.takeIf { activeConfig == null }?.let { startIntent -> - startMeasurement(startIntent).onFailure { stopMeasurement() } - } + else -> intent?.takeIf { activeConfig == null }?.let(::startRecordingHost) } - return START_NOT_STICKY } - private fun startMeasurement(intent: Intent): Result = appResult( - AppError.Kind.MEASUREMENT, - "Start measurement service", - ) { - val config = MeasurementConfig.from(intent) - activeConfig = config - promoteToForeground(config) - publishRunningSession(config) - configureRuntimeResources(config) - scheduleMeasurementStart(config) + private fun startRecordingHost(intent: Intent) { + appResult(AppErrorCode.MEASUREMENT, "Start recording foreground host") { + val config = MeasurementConfig.from(intent) + require(config.sessionId.isNotBlank()) { "Recording session ID is missing" } + activeConfig = config + isFinishing = false + promoteToForeground(config) + configureRuntimeResources(config) + val serviceController = ServiceController(this, config, serviceScope) + controller = serviceController + observeEngine(serviceController) + serviceScope.launch { + val result = serviceController.prepareAndCommit() + if (result.isFailure && !isFinishing) { + finishRecording(MeasurementStopReason.SOURCE_FAILURE, result) + } + } + }.onFailure { error -> + serviceScope.launch { + finishRecording( + MeasurementStopReason.SOURCE_FAILURE, + AppResult.failure(error), + ) + } + } } - private fun scheduleMeasurementStart(config: MeasurementConfig) { - startJob = serviceScope.launch { - delay((config.startAtEpochMillis - System.currentTimeMillis()).coerceAtLeast(0L)) - if (controller.start(this@MeasurementService, config).isFailure) { - stopMeasurement() - return@launch - } - scheduleAlarms(config) - if (config.durationMillis > 0L) { - durationJob = serviceScope.launch { - delay(config.durationMillis) - stopMeasurement() + private fun observeEngine(serviceController: ServiceController) { + eventJob?.cancel() + eventJob = serviceScope.launch { + serviceController.events.collect { event -> + when (event) { + is RecordingEvent.RecordingStarted -> onRecordingStarted(event) + + is RecordingEvent.RecordingStartRejected -> finishRecording( + MeasurementStopReason.SOURCE_FAILURE, + AppResult.failure(event.error), + ) + + is RecordingEvent.RecordingStopped -> finishRecording( + event.reason.toMeasurementReason(), + event.result, + ) } } } } + private fun onRecordingStarted(event: RecordingEvent.RecordingStarted) { + val config = activeConfig ?: return + sessionStore.markRunning( + MeasurementSessionState.Running( + sessionId = event.sessionId.value, + folderName = config.folderName, + startedAtElapsedRealtime = SystemClock.elapsedRealtime(), + sensorIds = config.sensorIds.toList(), + includesGps = config.includesGps, + ), + ) + scheduleAlarms(config) + } + + private fun requestStop(reason: RecordingStopReason) { + if (isFinishing) return + sessionStore.markStopping() + serviceScope.launch { + val result = controller?.stop(reason) ?: AppResult.success(Unit) + if (result.isFailure && !isFinishing) finishRecording(reason.toMeasurementReason(), result) + } + } + + private suspend fun finishRecording(reason: MeasurementStopReason, engineResult: AppResult) { + if (isFinishing) return + isFinishing = true + val sessionId = activeConfig?.sessionId.orEmpty() + val hostResult = finishHost() + val result = listOf(engineResult, hostResult) + .combineAppResults(AppErrorCode.MEASUREMENT, "Finish recording foreground host") + sessionStore.publishStopped(sessionId, reason, result) + eventJob?.cancel() + eventJob = null + } + + private fun finishHost(): AppResult { + val results = mutableListOf>() + results += releaseRuntimeResources() + activeConfig = null + controller = null + results += appResult(AppErrorCode.MEASUREMENT, "Publish idle measurement state") { + sessionStore.markIdle() + } + results += appResult(AppErrorCode.MEASUREMENT, "Remove measurement notification") { + stopForeground(STOP_FOREGROUND_REMOVE) + } + results += appResult(AppErrorCode.MEASUREMENT, "Stop measurement service instance") { + stopSelf() + } + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Finish recording host resources") + } + private fun scheduleAlarms(config: MeasurementConfig) { alarmJobs = config.alarmOffsetsSeconds.distinct().sorted().map { seconds -> serviceScope.launch { delay(seconds * 1_000L) - controller.playAlarm() + controller?.playAlarm() } } } @@ -130,18 +193,6 @@ class MeasurementService : Service() { FOREGROUND_SERVICE_TYPE_HEALTH } - private fun publishRunningSession(config: MeasurementConfig) { - val delayMillis = (config.startAtEpochMillis - System.currentTimeMillis()).coerceAtLeast(0L) - sessionStore.markRunning( - MeasurementSessionState.Running( - folderName = config.folderName, - startedAtElapsedRealtime = SystemClock.elapsedRealtime() + delayMillis, - sensorIds = config.sensorIds.toList(), - includesGps = config.includesGps, - ), - ) - } - private fun configureRuntimeResources(config: MeasurementConfig) { if (config.useWakeLock) acquireWakeLock() if (config.stopOnLowBattery) registerLowBatteryReceiver() @@ -149,9 +200,7 @@ class MeasurementService : Service() { private fun acquireWakeLock() { val powerManager = getSystemService(PowerManager::class.java) - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { - acquire() - } + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { acquire() } } private fun registerLowBatteryReceiver() { @@ -164,77 +213,60 @@ class MeasurementService : Service() { batteryReceiverRegistered = true } - private fun stopMeasurement() { - if (isStopping) return - isStopping = true - sessionStore.markStopping() - serviceScope.launch { - val failures = mutableListOf() - controller.stop(this@MeasurementService).exceptionOrNull()?.let(failures::add) - stopRemoteMeasurement().exceptionOrNull()?.let(failures::add) - finishService().exceptionOrNull()?.let(failures::add) - failures.firstOrNull()?.let { first -> - failures.drop(1).forEach(first::addSuppressed) - AppError.from(AppError.Kind.MEASUREMENT, "Stop measurement service", first) - } - } - } - - private suspend fun stopRemoteMeasurement(): Result { - val config = activeConfig ?: return Result.success(Unit) - if (config.useInternalStorage || !config.controlsWearMeasurement) return Result.success(Unit) - return WearCommandCodec.encode(WearCommand.StopMeasurement).suspendFlatMap { payload -> - sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) - } - } - - private fun finishService(): Result { - val results = mutableListOf>() - results += releaseRuntimeResources() - activeConfig = null - isStopping = false - results += appResult(AppError.Kind.MEASUREMENT, "Publish idle measurement state") { - sessionStore.markIdle() - } - results += appResult(AppError.Kind.MEASUREMENT, "Remove measurement notification") { - stopForeground(STOP_FOREGROUND_REMOVE) - } - results += appResult(AppError.Kind.MEASUREMENT, "Stop measurement service instance") { - stopSelf() - } - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Finish measurement service") - } - - private fun releaseRuntimeResources(): Result { - val results = mutableListOf>() - startJob?.cancel() - startJob = null - durationJob?.cancel() - durationJob = null + private fun releaseRuntimeResources(): AppResult { alarmJobs.forEach(Job::cancel) alarmJobs = emptyList() - results += appResult(AppError.Kind.MEASUREMENT, "Release measurement wake lock") { + val results = mutableListOf>() + results += appResult(AppErrorCode.MEASUREMENT, "Release measurement wake lock") { wakeLock?.takeIf(PowerManager.WakeLock::isHeld)?.release() + wakeLock = null } - wakeLock = null - results += appResult(AppError.Kind.MEASUREMENT, "Unregister low battery receiver") { + results += appResult(AppErrorCode.MEASUREMENT, "Unregister low battery receiver") { if (batteryReceiverRegistered) unregisterReceiver(lowBatteryReceiver) + batteryReceiverRegistered = false } - batteryReceiverRegistered = false - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Release measurement resources") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Release recording host resources") } override fun onDestroy() { - if (activeConfig != null && !isStopping) { - AppError(AppError.Kind.MEASUREMENT, "Measurement service destroyed before cleanup") + val config = activeConfig + if (config != null && !isFinishing) { + isFinishing = true + val cleanup = runBlocking(Dispatchers.IO) { + controller?.stop(RecordingStopReason.PLATFORM_DESTROYED) ?: AppResult.success(Unit) + } + val resources = releaseRuntimeResources() + sessionStore.markIdle() + sessionStore.publishStopped( + config.sessionId, + MeasurementStopReason.SERVICE_DESTROYED, + listOf(cleanup, resources).combineAppResults( + AppErrorCode.MEASUREMENT, + "Destroy recording foreground host", + ), + ) } - releaseRuntimeResources() - appResult(AppError.Kind.MEASUREMENT, "Publish destroyed measurement state") { sessionStore.markIdle() } + eventJob?.cancel() serviceScope.cancel() super.onDestroy() } + private fun RecordingStopReason.toMeasurementReason(): MeasurementStopReason = when (this) { + RecordingStopReason.USER_REQUEST, + RecordingStopReason.PAIRED_ABORT, + -> MeasurementStopReason.USER_REQUEST + + RecordingStopReason.DURATION_EXPIRED -> MeasurementStopReason.DURATION_EXPIRED + + RecordingStopReason.LOW_BATTERY -> MeasurementStopReason.LOW_BATTERY + + RecordingStopReason.SOURCE_FAILURE -> MeasurementStopReason.SOURCE_FAILURE + + RecordingStopReason.PLATFORM_DESTROYED -> MeasurementStopReason.SERVICE_DESTROYED + } + companion object { + const val SESSION_ID = "SESSION_ID" const val FOLDER_NAME = "FOLDER_NAME" const val INTERNAL_STORAGE = "INTERNAL_STORAGE" const val ANDROID_SENSORS = "ANDROID_SENSORS" @@ -252,7 +284,6 @@ class MeasurementService : Service() { const val ACTIVITY_RECOGNITION = "ACTIVITY_RECOGNITION" const val ACTIVITY_RECOGNITION_PERIOD_SECONDS = "ACTIVITY_RECOGNITION_PERIOD_SECONDS" const val SIGNIFICANT_MOTION = "SIGNIFICANT_MOTION" - const val CONTROLS_WEAR_MEASUREMENT = "CONTROLS_WEAR_MEASUREMENT" const val ACTION_ANNOTATE = "com.motionapps.sensorbox.action.ANNOTATE_MEASUREMENT" const val ANNOTATION_TIME = "ANNOTATION_TIME" const val ANNOTATION_TEXT = "ANNOTATION_TEXT" diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt index 580a029..d89c7b4 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt @@ -4,6 +4,7 @@ sealed interface MeasurementSessionState { data object Idle : MeasurementSessionState data class Running( + val sessionId: String, val folderName: String, val startedAtElapsedRealtime: Long, val sensorIds: List, diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt index 0b2684f..9e751b0 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt @@ -1,6 +1,9 @@ package com.motionapps.sensorservices.session +import com.motionapps.sensorbox.core.error.AppResult +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject @@ -9,7 +12,9 @@ import javax.inject.Singleton @Singleton class MeasurementSessionStore @Inject constructor() { private val mutableState = MutableStateFlow(MeasurementSessionState.Idle) + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) val state: StateFlow = mutableState.asStateFlow() + val events: SharedFlow = mutableEvents fun markRunning(state: MeasurementSessionState.Running) { mutableState.value = state @@ -22,4 +27,25 @@ class MeasurementSessionStore @Inject constructor() { fun markIdle() { mutableState.value = MeasurementSessionState.Idle } + + fun publishStopped(sessionId: String, reason: MeasurementStopReason, result: AppResult) { + mutableEvents.tryEmit(MeasurementSessionEvent.Stopped(sessionId, reason, result)) + } + + private companion object { + const val EVENT_BUFFER_SIZE = 16 + } +} + +enum class MeasurementStopReason { + USER_REQUEST, + DURATION_EXPIRED, + LOW_BATTERY, + SOURCE_FAILURE, + SERVICE_DESTROYED, +} + +sealed interface MeasurementSessionEvent { + data class Stopped(val sessionId: String, val reason: MeasurementStopReason, val result: AppResult) : + MeasurementSessionEvent } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt index 12ca4d3..fe3b753 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt @@ -4,6 +4,8 @@ import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.suspendAppResult @@ -25,7 +27,7 @@ class SensorHolder(val spec: SensorSpec, outputStream: OutputStream) : SensorEve writer.append(spec.header) for (sample in samples) writer.appendLine(sample.toCsv(spec.axisCount)) } catch (error: IOException) { - writerFailure = AppError.from(AppError.Kind.STORAGE, "Write ${spec.fileName}", error) + writerFailure = AppError.from(AppErrorCode.STORAGE, "Write ${spec.fileName}", error) samples.close() } } @@ -44,21 +46,21 @@ class SensorHolder(val spec: SensorSpec, outputStream: OutputStream) : SensorEve ), ) if (result.isFailure && writerFailure == null) { - writerFailure = AppError(AppError.Kind.STORAGE, "Buffer ${spec.fileName}") + writerFailure = AppError(AppErrorCode.STORAGE, "Buffer ${spec.fileName}") } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit - suspend fun close(): Result { + suspend fun close(): AppResult { samples.close() - val results = mutableListOf>() - results += suspendAppResult(AppError.Kind.STORAGE, "Finish ${spec.fileName} writer") { writerJob.await() } - writerFailure?.let { results += Result.failure(it) } - results += appResult(AppError.Kind.STORAGE, "Flush ${spec.fileName}") { writer.flush() } - results += appResult(AppError.Kind.STORAGE, "Close ${spec.fileName}") { writer.close() } + val results = mutableListOf>() + results += suspendAppResult(AppErrorCode.STORAGE, "Finish ${spec.fileName} writer") { writerJob.await() } + writerFailure?.let { results += AppResult.failure(it) } + results += appResult(AppErrorCode.STORAGE, "Flush ${spec.fileName}") { writer.flush() } + results += appResult(AppErrorCode.STORAGE, "Close ${spec.fileName}") { writer.close() } scope.cancel() - return results.combineAppResults(AppError.Kind.STORAGE, "Close ${spec.fileName}") + return results.combineAppResults(AppErrorCode.STORAGE, "Close ${spec.fileName}") } } diff --git a/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt b/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt index c0ea808..2d2d40c 100644 --- a/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt +++ b/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt @@ -2,6 +2,7 @@ package com.motionapps.sensorservices.types import android.hardware.Sensor import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -17,9 +18,9 @@ class SensorHolderErrorTest { val result = holder.close() - val error = result.exceptionOrNull() + val error = result.errorOrNull() assertTrue(error is AppError) - assertEquals(AppError.Kind.STORAGE, (error as AppError).kind) + assertEquals(AppErrorCode.STORAGE, (error as AppError).code) } private class FailingOutputStream : OutputStream() {