From 1505f35ca1be34ae0eb1635e3c3bb425f02e98af Mon Sep 17 00:00:00 2001 From: Foxpace Date: Sat, 15 Aug 2026 23:02:31 +0200 Subject: [PATCH 01/32] refactor: add shared core foundations --- .../sensorbox/core/error/AppDiagnostics.kt | 112 +++++++++++ .../sensorbox/core/error/AppError.kt | 73 +++++++ .../core/preferences/AppPreferences.kt | 13 ++ .../AppPreferencesDataStoreFactory.kt | 15 ++ .../core/preferences/AppPreferencesIntent.kt | 21 ++ .../core/preferences/AppPreferencesReducer.kt | 35 ++++ .../preferences/AppPreferencesRepository.kt | 9 + .../DataStoreAppPreferencesRepository.kt | 68 +++++++ .../core/storage/NativeDocumentStorage.kt | 184 ++++++++++++++++++ .../sensorbox/core/error/AppErrorTest.kt | 32 +++ .../preferences/AppPreferencesReducerTest.kt | 56 ++++++ .../core/testing/AppPreferencesFixtures.kt | 10 + .../testing/FakeAppPreferencesRepository.kt | 21 ++ 13 files changed, 649 insertions(+) create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferences.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesDataStoreFactory.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesIntent.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducer.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt create mode 100644 core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt create mode 100644 core/src/test/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducerTest.kt create mode 100644 core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/AppPreferencesFixtures.kt create mode 100644 core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt new file mode 100644 index 0000000..bed03b5 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt @@ -0,0 +1,112 @@ +package com.motionapps.sensorbox.core.error + +import android.content.Context +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object AppDiagnostics { + private val lock = Any() + private val pending = ArrayDeque() + + @Volatile + private var applicationContext: Context? = null + + @Volatile + private var uncaughtHandlerInstalled = false + + fun install(context: Context) { + synchronized(lock) { + applicationContext = context.applicationContext + installUncaughtExceptionHandler() + val queued = pending.toList() + pending.clear() + queued.forEach(::appendSafely) + } + } + + private fun installUncaughtExceptionHandler() { + if (uncaughtHandlerInstalled) return + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + AppError.from(AppError.Kind.UNKNOWN, "Uncaught exception on ${thread.name}", error) + previous?.uncaughtException(thread, error) + } + uncaughtHandlerInstalled = true + } + + fun record(error: AppError) { + val entry = error.toDiagnosticEntry() + synchronized(lock) { + if (applicationContext == null) { + if (pending.size == MAX_PENDING_ENTRIES) pending.removeFirst() + pending.addLast(entry) + } else { + appendSafely(entry) + } + } + } + + fun readText(): Result = appResult(AppError.Kind.STORAGE, "Read diagnostics") { + synchronized(lock) { + val file = diagnosticsFile() + if (file.exists()) file.readText() else NO_DIAGNOSTICS + } + } + + fun exportFile(): Result = appResult(AppError.Kind.STORAGE, "Export diagnostics") { + synchronized(lock) { + diagnosticsFile().also { file -> + file.parentFile?.mkdirs() + if (!file.exists()) file.writeText(NO_DIAGNOSTICS) + } + } + } + + fun clear(): Result = appResult(AppError.Kind.STORAGE, "Clear diagnostics") { + synchronized(lock) { + val file = diagnosticsFile() + check(!file.exists() || file.delete()) { "Unable to delete diagnostics" } + } + } + + private fun appendSafely(entry: String) { + try { + val file = diagnosticsFile() + file.parentFile?.mkdirs() + if (file.length() + entry.length > MAX_FILE_BYTES) rotate(file) + file.appendText(entry) + } catch (_: Throwable) { + // Diagnostics must never become a second failure source. + } + } + + private fun rotate(file: File) { + val previous = File(file.parentFile, PREVIOUS_FILE_NAME) + if (previous.exists()) previous.delete() + if (file.exists()) file.renameTo(previous) + } + + private fun diagnosticsFile(): File { + val context = checkNotNull(applicationContext) { "Diagnostics are not initialized" } + return File(File(context.filesDir, DIRECTORY_NAME), FILE_NAME) + } + + private fun AppError.toDiagnosticEntry(): String = buildString { + val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date()) + append(timestamp).append(" | ").append(kind).append(" | ").append(operation).appendLine() + append(stackTraceToString().take(MAX_ENTRY_CHARS)).appendLine() + appendLine(ENTRY_SEPARATOR) + } + + private const val DIRECTORY_NAME = "diagnostics" + private const val FILE_NAME = "sensorbox-diagnostics.txt" + private const val PREVIOUS_FILE_NAME = "sensorbox-diagnostics-previous.txt" + private const val TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + private const val ENTRY_SEPARATOR = "---" + private const val NO_DIAGNOSTICS = "No diagnostics have been recorded.\n" + private const val MAX_PENDING_ENTRIES = 20 + private const val MAX_ENTRY_CHARS = 32_000 + private const val MAX_FILE_BYTES = 1_000_000L +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt new file mode 100644 index 0000000..5fd0da9 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt @@ -0,0 +1,73 @@ +package com.motionapps.sensorbox.core.error + +import kotlinx.coroutines.CancellationException + +class AppError(val kind: Kind, val operation: String, cause: Throwable? = null) : + Exception(message(operation, cause), cause) { + init { + AppDiagnostics.record(this) + } + + enum class Kind { + CONNECTIVITY, + EXTERNAL_ACTION, + MEASUREMENT, + PERMISSION, + PREFERENCES, + STORAGE, + UNKNOWN, + } + + companion object { + fun from(kind: Kind, operation: String, cause: Throwable): AppError = + cause as? AppError ?: AppError(kind, operation, cause) + + private fun message(operation: String, cause: Throwable?): String = + cause?.message?.takeIf(String::isNotBlank)?.let { "$operation: $it" } ?: "$operation failed" + } +} + +@Suppress("TooGenericExceptionCaught") +inline fun appResult(kind: AppError.Kind, operation: String, block: () -> T): Result = try { + Result.success(block()) +} catch (error: CancellationException) { + throw error +} catch (error: Throwable) { + Result.failure(AppError.from(kind, operation, error)) +} + +@Suppress("TooGenericExceptionCaught") +suspend inline fun suspendAppResult( + kind: AppError.Kind, + operation: String, + crossinline block: suspend () -> T, +): Result = try { + Result.success(block()) +} catch (error: CancellationException) { + throw error +} catch (error: Throwable) { + Result.failure(AppError.from(kind, operation, error)) +} + +fun Result.withAppError(kind: AppError.Kind, operation: String): Result = fold( + onSuccess = Result.Companion::success, + onFailure = { Result.failure(AppError.from(kind, operation, it)) }, +) + +inline fun Result.flatMap(transform: (T) -> Result): Result = fold( + onSuccess = transform, + onFailure = Result.Companion::failure, +) + +suspend inline fun Result.suspendFlatMap(crossinline transform: suspend (T) -> Result): Result = fold( + onSuccess = { transform(it) }, + onFailure = Result.Companion::failure, +) + +fun Iterable>.combineAppResults(kind: AppError.Kind, operation: String): Result { + val failures = mapNotNull(Result<*>::exceptionOrNull) + if (failures.isEmpty()) return Result.success(Unit) + val first = failures.first() + failures.drop(1).forEach(first::addSuppressed) + return Result.failure(AppError.from(kind, operation, first)) +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferences.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferences.kt new file mode 100644 index 0000000..773130d --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferences.kt @@ -0,0 +1,13 @@ +package com.motionapps.sensorbox.core.preferences + +data class AppPreferences( + val hasCompletedIntro: Boolean = false, + val hasAcceptedPolicy: Boolean = false, + val gpsIntervalSeconds: Int = 10, + val gpsMinDistanceMeters: Int = 20, + val sensorSamplingPeriod: Int = 0, + val restrictMeasurementOnLowBattery: Boolean = true, + val useWakeLock: Boolean = false, + val keepPhoneDisplayOn: Boolean = false, + val keepWearDisplayOn: Boolean = false, +) diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesDataStoreFactory.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesDataStoreFactory.kt new file mode 100644 index 0000000..ddb83ca --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesDataStoreFactory.kt @@ -0,0 +1,15 @@ +package com.motionapps.sensorbox.core.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile + +object AppPreferencesDataStoreFactory { + fun create(context: Context): DataStore = PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile(FILE_NAME) }, + ) + + private const val FILE_NAME = "sensorbox" +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesIntent.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesIntent.kt new file mode 100644 index 0000000..69da124 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesIntent.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.core.preferences + +sealed interface AppPreferencesIntent { + data object CompleteIntro : AppPreferencesIntent + + data object AcceptPolicy : AppPreferencesIntent + + data class SetGpsInterval(val seconds: Int) : AppPreferencesIntent + + data class SetGpsMinDistance(val meters: Int) : AppPreferencesIntent + + data class SetSensorSamplingPeriod(val period: Int) : AppPreferencesIntent + + data class SetLowBatteryRestriction(val enabled: Boolean) : AppPreferencesIntent + + data class SetWakeLock(val enabled: Boolean) : AppPreferencesIntent + + data class SetKeepPhoneDisplayOn(val enabled: Boolean) : AppPreferencesIntent + + data class SetKeepWearDisplayOn(val enabled: Boolean) : AppPreferencesIntent +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducer.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducer.kt new file mode 100644 index 0000000..50b0969 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducer.kt @@ -0,0 +1,35 @@ +package com.motionapps.sensorbox.core.preferences + +object AppPreferencesReducer { + fun reduce(current: AppPreferences, intent: AppPreferencesIntent): AppPreferences = when (intent) { + AppPreferencesIntent.AcceptPolicy -> current.copy(hasAcceptedPolicy = true) + + AppPreferencesIntent.CompleteIntro -> current.copy(hasCompletedIntro = true) + + is AppPreferencesIntent.SetGpsInterval -> current.copy( + gpsIntervalSeconds = intent.seconds.coerceAtLeast(1), + ) + + is AppPreferencesIntent.SetGpsMinDistance -> current.copy( + gpsMinDistanceMeters = intent.meters.coerceAtLeast(0), + ) + + is AppPreferencesIntent.SetKeepWearDisplayOn -> current.copy( + keepWearDisplayOn = intent.enabled, + ) + + is AppPreferencesIntent.SetKeepPhoneDisplayOn -> current.copy( + keepPhoneDisplayOn = intent.enabled, + ) + + is AppPreferencesIntent.SetLowBatteryRestriction -> current.copy( + restrictMeasurementOnLowBattery = intent.enabled, + ) + + is AppPreferencesIntent.SetSensorSamplingPeriod -> current.copy( + sensorSamplingPeriod = intent.period, + ) + + is AppPreferencesIntent.SetWakeLock -> current.copy(useWakeLock = intent.enabled) + } +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt new file mode 100644 index 0000000..5ab2def --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt @@ -0,0 +1,9 @@ +package com.motionapps.sensorbox.core.preferences + +import kotlinx.coroutines.flow.Flow + +interface AppPreferencesRepository { + val preferences: Flow> + + suspend fun dispatch(intent: AppPreferencesIntent): Result +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt new file mode 100644 index 0000000..aecb395 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt @@ -0,0 +1,68 @@ +package com.motionapps.sensorbox.core.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.suspendAppResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +class DataStoreAppPreferencesRepository(private val dataStore: DataStore) : AppPreferencesRepository { + override val preferences: Flow> = dataStore.data + .map { values -> Result.success(values.toAppPreferences()) } + .catch { error -> + if (error is CancellationException) throw error + emit(Result.failure(AppError.from(AppError.Kind.PREFERENCES, "Read preferences", error))) + } + + override suspend fun dispatch(intent: AppPreferencesIntent): Result = suspendAppResult( + AppError.Kind.PREFERENCES, + "Update preferences", + ) { + dataStore.edit { values -> + val updated = AppPreferencesReducer.reduce(values.toAppPreferences(), intent) + values.write(updated) + } + } + + private fun Preferences.toAppPreferences(): AppPreferences = AppPreferences( + hasCompletedIntro = this[Keys.COMPLETED_INTRO] ?: false, + hasAcceptedPolicy = this[Keys.ACCEPTED_POLICY] ?: false, + gpsIntervalSeconds = this[Keys.GPS_INTERVAL] ?: 10, + gpsMinDistanceMeters = this[Keys.GPS_DISTANCE] ?: 20, + sensorSamplingPeriod = this[Keys.SAMPLING_PERIOD] ?: 0, + restrictMeasurementOnLowBattery = this[Keys.LOW_BATTERY] ?: true, + useWakeLock = this[Keys.WAKE_LOCK] ?: false, + keepPhoneDisplayOn = this[Keys.KEEP_PHONE_DISPLAY_ON] ?: false, + keepWearDisplayOn = this[Keys.KEEP_DISPLAY_ON] ?: false, + ) + + private fun androidx.datastore.preferences.core.MutablePreferences.write(value: AppPreferences) { + this[Keys.COMPLETED_INTRO] = value.hasCompletedIntro + this[Keys.ACCEPTED_POLICY] = value.hasAcceptedPolicy + this[Keys.GPS_INTERVAL] = value.gpsIntervalSeconds + this[Keys.GPS_DISTANCE] = value.gpsMinDistanceMeters + this[Keys.SAMPLING_PERIOD] = value.sensorSamplingPeriod + this[Keys.LOW_BATTERY] = value.restrictMeasurementOnLowBattery + this[Keys.WAKE_LOCK] = value.useWakeLock + this[Keys.KEEP_PHONE_DISPLAY_ON] = value.keepPhoneDisplayOn + this[Keys.KEEP_DISPLAY_ON] = value.keepWearDisplayOn + } + + private object Keys { + val COMPLETED_INTRO = booleanPreferencesKey("completed_intro") + val ACCEPTED_POLICY = booleanPreferencesKey("accepted_policy") + val GPS_INTERVAL = intPreferencesKey("gps_interval_seconds") + val GPS_DISTANCE = intPreferencesKey("gps_min_distance_meters") + val SAMPLING_PERIOD = intPreferencesKey("sensor_sampling_period") + val LOW_BATTERY = booleanPreferencesKey("restrict_on_low_battery") + val WAKE_LOCK = booleanPreferencesKey("use_wake_lock") + val KEEP_PHONE_DISPLAY_ON = booleanPreferencesKey("keep_phone_display_on") + val KEEP_DISPLAY_ON = booleanPreferencesKey("keep_wear_display_on") + } +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt b/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt new file mode 100644 index 0000000..9557d42 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt @@ -0,0 +1,184 @@ +package com.motionapps.sensorbox.core.storage + +import android.content.Context +import android.content.Intent +import androidx.documentfile.provider.DocumentFile +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.core.error.flatMap +import java.io.InputStream +import java.io.OutputStream + +object NativeDocumentStorage { + fun persistRootAccess(context: Context, intent: Intent, appDirectoryName: String): Result { + val uri = intent.data ?: return storageFailure("Storage directory was not selected") + val grantFlags = intent.flags and READ_WRITE_FLAGS + if (grantFlags == 0) return storageFailure("Storage permission was not granted") + return appResult(AppError.Kind.STORAGE, "Persist storage permission") { + context.contentResolver.takePersistableUriPermission(uri, grantFlags) + DocumentFile.fromTreeUri(context, uri) + }.flatMap { selectedDirectory -> + if (selectedDirectory?.isDirectory != true) { + return@flatMap storageFailure("Selected storage location is not a directory") + } + releaseOtherRootPermissions(context, uri) + if (appDirectory(context, appDirectoryName, create = true) == null) { + storageFailure("Storage directory is unavailable") + } else { + Result.success(Unit) + } + } + } + + fun hasAppDirectory(context: Context, appDirectoryName: String): Result = appResult( + AppError.Kind.STORAGE, + "Check storage directory", + ) { + appDirectory(context, appDirectoryName, create = false)?.exists() == true + } + + fun displayPath(context: Context, appDirectoryName: String): Result = appResult( + AppError.Kind.STORAGE, + "Read storage path", + ) { + val selectedDirectory = appDirectory(context, appDirectoryName, create = false) ?: return@appResult null + selectedDirectory.name ?: selectedDirectory.uri.lastPathSegment + } + + fun createMeasurementDirectory(context: Context, appDirectoryName: String, measurementName: String): Result = + appResult(AppError.Kind.STORAGE, "Access measurement root") { + appDirectory(context, appDirectoryName, create = false) + }.flatMap { appDirectory -> + if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") + appResult(AppError.Kind.STORAGE, "Create measurement directory") { + findDirectory(appDirectory, measurementName) != null || + appDirectory.createDirectory(measurementName) != null + }.flatMap { created -> + if (created) Result.success(Unit) else storageFailure("Unable to create measurement directory") + } + } + + fun openMeasurementFile( + context: Context, + appDirectoryName: String, + measurementName: String, + mimeType: String, + fileName: String, + replaceExisting: Boolean = false, + ): Result = appResult(AppError.Kind.STORAGE, "Access measurement directory") { + measurementDirectory(context, appDirectoryName, measurementName) + }.flatMap { directory -> + if (directory == null) return@flatMap storageFailure("Measurement directory is unavailable") + createOrReplaceFile(directory, mimeType, fileName, replaceExisting).flatMap { createdFile -> + if (createdFile == null) return@flatMap storageFailure("Unable to create measurement file") + appResult(AppError.Kind.STORAGE, "Open measurement file") { + context.contentResolver.openOutputStream(createdFile.uri, "wt") + }.flatMap { output -> + output?.let(Result.Companion::success) ?: storageFailure("Unable to open measurement file") + } + } + } + + fun deleteMeasurement(context: Context, appDirectoryName: String, measurementName: String): Result = + appResult(AppError.Kind.STORAGE, "Access measurement directory") { + appDirectory(context, appDirectoryName, create = false) + }.flatMap { appDirectory -> + if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") + val directory = findDirectory(appDirectory, measurementName) + ?: return@flatMap storageFailure("Measurement does not exist") + appResult(AppError.Kind.STORAGE, "Delete measurement") { directory.delete() }.flatMap { deleted -> + if (deleted) Result.success(Unit) else storageFailure("Unable to delete measurement") + } + } + + fun copyToMeasurement( + context: Context, + input: InputStream, + appDirectoryName: String, + measurementName: String, + fileName: String, + mimeType: String, + ): Result = openMeasurementFile( + context = context, + appDirectoryName = appDirectoryName, + measurementName = measurementName, + mimeType = mimeType, + fileName = fileName, + replaceExisting = true, + ).flatMap { output -> + appResult(AppError.Kind.STORAGE, "Copy measurement file") { + input.use { source -> output.use(source::copyTo) } + Unit + } + } + + private fun measurementDirectory( + context: Context, + appDirectoryName: String, + measurementName: String, + ): DocumentFile? { + val appDirectory = appDirectory(context, appDirectoryName, create = false) ?: return null + return findDirectory(appDirectory, measurementName) + ?: appDirectory.createDirectory(measurementName) + } + + private fun appDirectory( + context: Context, + @Suppress("UNUSED_PARAMETER") directoryName: String, + @Suppress("UNUSED_PARAMETER") create: Boolean, + ): DocumentFile? = persistedRoot(context)?.takeIf(DocumentFile::isDirectory) + + private fun persistedRoot(context: Context): DocumentFile? { + val permission = context.contentResolver.persistedUriPermissions + .filter { it.isReadPermission && it.isWritePermission } + .maxByOrNull { it.persistedTime } + ?: return null + return DocumentFile.fromTreeUri(context, permission.uri) + } + + private fun findDirectory(parent: DocumentFile, name: String): DocumentFile? = + parent.findFile(name)?.takeIf(DocumentFile::isDirectory) + + private const val READ_WRITE_FLAGS = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION +} + +private fun createOrReplaceFile( + directory: DocumentFile, + mimeType: String, + fileName: String, + replaceExisting: Boolean, +): Result = appResult(AppError.Kind.STORAGE, "Create measurement file") { + val existing = directory.findFile(fileName) + if (replaceExisting && existing != null) { + if (existing.delete()) directory.createFile(normalizeMimeType(mimeType), fileName) else null + } else { + existing ?: directory.createFile(normalizeMimeType(mimeType), fileName) + } +} + +private fun storageFailure(operation: String): Result = + Result.failure(AppError(AppError.Kind.STORAGE, operation)) + +private fun releaseOtherRootPermissions(context: Context, selectedUri: android.net.Uri) { + val resolver = context.contentResolver + resolver.persistedUriPermissions + .filter { it.uri != selectedUri } + .forEach { permission -> + val flags = + (if (permission.isReadPermission) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0) or + (if (permission.isWritePermission) Intent.FLAG_GRANT_WRITE_URI_PERMISSION else 0) + if (flags != 0) { + appResult(AppError.Kind.STORAGE, "Release old storage permission") { + resolver.releasePersistableUriPermission(permission.uri, flags) + } + } + } +} + +private fun normalizeMimeType(value: String): String = when (value.lowercase()) { + "csv" -> "text/csv" + "json" -> "application/json" + "txt" -> "text/plain" + else -> value.takeIf { '/' in it } ?: "application/octet-stream" +} diff --git a/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt new file mode 100644 index 0000000..6623341 --- /dev/null +++ b/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt @@ -0,0 +1,32 @@ +package com.motionapps.sensorbox.core.error + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppErrorTest { + @Test + fun `Given an operation failure When captured Then AppError is returned`() { + val cause = IllegalStateException("disk unavailable") + + val result = appResult(AppError.Kind.STORAGE, "Write file") { throw cause } + + val error = result.exceptionOrNull() + assertTrue(error is AppError) + assertEquals(AppError.Kind.STORAGE, (error as AppError).kind) + assertEquals("Write file", error.operation) + assertSame(cause, error.cause) + } + + @Test(expected = CancellationException::class) + fun `Given coroutine cancellation When captured Then cancellation is rethrown`() { + runBlocking { + suspendAppResult(AppError.Kind.CONNECTIVITY, "Send message") { + throw CancellationException("cancelled") + } + } + } +} diff --git a/core/src/test/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducerTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducerTest.kt new file mode 100644 index 0000000..e5d47d2 --- /dev/null +++ b/core/src/test/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducerTest.kt @@ -0,0 +1,56 @@ +package com.motionapps.sensorbox.core.preferences + +import com.motionapps.sensorbox.core.testing.AppPreferencesFixtures +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppPreferencesReducerTest { + @Test + fun `Given fresh preferences When intro completes Then completion is retained`() { + val givenPreferences = AppPreferencesFixtures.preferences() + + val actual = AppPreferencesReducer.reduce( + current = givenPreferences, + intent = AppPreferencesIntent.CompleteIntro, + ) + + assertTrue(actual.hasCompletedIntro) + } + + @Test + fun `Given any preferences When invalid GPS interval is submitted Then it is clamped`() { + val givenPreferences = AppPreferencesFixtures.preferences(gpsIntervalSeconds = 10) + + val actual = AppPreferencesReducer.reduce( + current = givenPreferences, + intent = AppPreferencesIntent.SetGpsInterval(seconds = 0), + ) + + assertEquals(1, actual.gpsIntervalSeconds) + } + + @Test + fun `Given battery protection enabled When disabled Then the preference changes`() { + val givenPreferences = AppPreferencesFixtures.preferences().copy(restrictMeasurementOnLowBattery = true) + + val actual = AppPreferencesReducer.reduce( + current = givenPreferences, + intent = AppPreferencesIntent.SetLowBatteryRestriction(false), + ) + + assertEquals(false, actual.restrictMeasurementOnLowBattery) + } + + @Test + fun `Given screen awake disabled When enabled Then the preference changes`() { + val givenPreferences = AppPreferencesFixtures.preferences().copy(keepPhoneDisplayOn = false) + + val actual = AppPreferencesReducer.reduce( + current = givenPreferences, + intent = AppPreferencesIntent.SetKeepPhoneDisplayOn(true), + ) + + assertTrue(actual.keepPhoneDisplayOn) + } +} diff --git a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/AppPreferencesFixtures.kt b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/AppPreferencesFixtures.kt new file mode 100644 index 0000000..082733a --- /dev/null +++ b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/AppPreferencesFixtures.kt @@ -0,0 +1,10 @@ +package com.motionapps.sensorbox.core.testing + +import com.motionapps.sensorbox.core.preferences.AppPreferences + +object AppPreferencesFixtures { + fun preferences(gpsIntervalSeconds: Int = 10, gpsMinDistanceMeters: Int = 20): AppPreferences = AppPreferences( + gpsIntervalSeconds = gpsIntervalSeconds, + gpsMinDistanceMeters = gpsMinDistanceMeters, + ) +} diff --git a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt new file mode 100644 index 0000000..c6ce1eb --- /dev/null +++ b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.core.testing + +import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent +import com.motionapps.sensorbox.core.preferences.AppPreferencesReducer +import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class FakeAppPreferencesRepository( + initial: AppPreferences = AppPreferences(), +) : AppPreferencesRepository { + private val mutablePreferences = MutableStateFlow(initial) + + override val preferences = mutablePreferences.map(Result.Companion::success) + + override suspend fun dispatch(intent: AppPreferencesIntent): Result { + mutablePreferences.value = AppPreferencesReducer.reduce(mutablePreferences.value, intent) + return Result.success(Unit) + } +} From 516ff4bbae75723dd729570d548362f0e2eafc2c Mon Sep 17 00:00:00 2001 From: Foxpace Date: Sat, 15 Aug 2026 23:02:31 +0200 Subject: [PATCH 02/32] build: migrate project tooling to Kotlin DSL --- .gitignore | 3 + WearOsLib/build.gradle | 52 ----- WearOsLib/build.gradle.kts | 57 ++++++ app/build.gradle | 116 ----------- app/build.gradle.kts | 99 +++++++++ build.gradle | 30 --- build.gradle.kts | 23 +++ config/detekt/detekt.yml | 165 +++++++++++++++ core/build.gradle.kts | 32 +++ gradle.properties | 13 +- gradle/gradle-daemon-jvm.properties | 12 ++ gradle/libs.versions.toml | 73 +++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 9 +- gradlew | 248 +++++++++++++++++++++++ gradlew.bat | 82 ++++++++ sensorservices/build.gradle | 57 ------ sensorservices/build.gradle.kts | 53 +++++ settings.gradle | 7 - settings.gradle.kts | 34 ++++ wear/build.gradle | 67 ------ wear/build.gradle.kts | 94 +++++++++ 22 files changed, 987 insertions(+), 339 deletions(-) delete mode 100644 WearOsLib/build.gradle create mode 100644 WearOsLib/build.gradle.kts delete mode 100644 app/build.gradle create mode 100644 app/build.gradle.kts delete mode 100644 build.gradle create mode 100644 build.gradle.kts create mode 100644 config/detekt/detekt.yml create mode 100644 core/build.gradle.kts create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100755 gradlew create mode 100644 gradlew.bat delete mode 100644 sensorservices/build.gradle create mode 100644 sensorservices/build.gradle.kts delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts delete mode 100644 wear/build.gradle create mode 100644 wear/build.gradle.kts diff --git a/.gitignore b/.gitignore index 124379e..618ac91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /local.properties /.gradle/ +/.kotlin/ +build/ /.idea/ /app/*.json /wear/*.json +/.codebase-memory/ diff --git a/WearOsLib/build.gradle b/WearOsLib/build.gradle deleted file mode 100644 index 31d87e6..0000000 --- a/WearOsLib/build.gradle +++ /dev/null @@ -1,52 +0,0 @@ -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' - -android { - compileSdk 34 - - defaultConfig { - minSdkVersion 24 - targetSdkVersion 34 - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles "consumer-rules.pro" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - nfrelease{ - initWith debug - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - namespace 'com.motionapps.wearoslib' -} - -dependencies { - implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation project(path: ':flipper') - - implementation 'androidx.preference:preference-ktx:1.2.1' - implementation 'androidx.core:core-ktx:1.10.1' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - - implementation 'com.google.android.gms:play-services-wearable:18.1.0' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' - - implementation 'com.github.GrenderG:Toasty:1.5.2' -} \ No newline at end of file diff --git a/WearOsLib/build.gradle.kts b/WearOsLib/build.gradle.kts new file mode 100644 index 0000000..4697001 --- /dev/null +++ b/WearOsLib/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.motionapps.wearoslib" + compileSdk = 37 + + defaultConfig { + minSdk = 24 + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + create("nfrelease") { + initWith(getByName("release")) + matchingFallbacks += "release" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testFixtures { + enable = true + } +} + +dependencies { + implementation(project(":core")) + + implementation(libs.androidx.core.ktx) + implementation(libs.play.services.wearable) + implementation(libs.coroutines.core) + implementation(libs.coroutines.play.services) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + testImplementation(libs.junit) + testFixturesImplementation(libs.coroutines.core) +} + +hilt { + enableAggregatingTask = true +} diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 84a5eac..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,116 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'dagger.hilt.android.plugin' -apply plugin: 'kotlin-android' -apply plugin: "androidx.navigation.safeargs.kotlin" -apply plugin: 'kotlin-kapt' -apply plugin: 'com.google.gms.google-services' -apply plugin: 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' - -if (getGradle().getStartParameter().getTaskRequests().toString().contains("Release")) { - apply plugin: 'com.google.firebase.crashlytics' -} - -android { - compileSdk 34 - - defaultConfig { - applicationId "motionapps.sensorbox" - minSdkVersion 24 - targetSdkVersion 34 - versionCode 87 - versionName "4.3.2" - multiDexEnabled true - vectorDrawables.useSupportLibrary = true - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - signingConfig signingConfigs.debug - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - - nfrelease{ - initWith release - } - } - - - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - lint { - disable 'MissingTranslation' - } - namespace 'com.motionapps.sensorbox' - - -} - -dependencies { - implementation fileTree(dir: "libs", include: ["*.jar"]) - - implementation project(path: ':SensorServices') - implementation project(path: ':CountDownDialog') - implementation project(path: ':wearoslib') - implementation project(path: ':flipper') - - implementation "androidx.multidex:multidex:2.0.1" - implementation 'androidx.core:core-ktx:1.12.0' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.11.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - - implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' - implementation 'androidx.navigation:navigation-ui-ktx:2.7.7' - implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - - implementation 'com.google.android.gms:play-services-maps:18.2.0' - implementation 'com.google.android.gms:play-services-location:21.1.0' - implementation 'com.google.android.gms:play-services-basement:18.3.0' - implementation 'com.google.android.gms:play-services-wearable:18.1.0' - implementation 'androidx.preference:preference-ktx:1.2.1' - - releaseImplementation 'com.google.firebase:firebase-analytics-ktx:21.5.1' - releaseImplementation 'com.google.firebase:firebase-crashlytics-ktx:18.6.2' - - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - - implementation 'androidx.fragment:fragment-ktx:1.6.2' - - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' - implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" - - implementation "com.google.dagger:hilt-android:$hilt_version" - kapt "com.google.dagger:hilt-compiler:$hilt_version" - - implementation 'com.jjoe64:graphview:4.2.2' - implementation 'com.github.AppIntro:AppIntro:6.1.0' - implementation 'com.afollestad.material-dialogs:core:3.3.0' - implementation 'io.github.ShawnLin013:number-picker:2.4.13' - implementation 'com.jaredrummler:material-spinner:1.3.1' - implementation 'io.github.medyo:android-about-page:2.0.0' - implementation 'de.psdev.licensesdialog:licensesdialog:2.2.0' - implementation 'com.github.GrenderG:Toasty:1.5.2' - - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' - -} - -kapt { - correctErrorTypes true -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..24254d1 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,99 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.oss.licenses) +} + +android { + namespace = "com.motionapps.sensorbox" + compileSdk = 37 + + defaultConfig { + applicationId = "motionapps.sensorbox" + minSdk = 24 + targetSdk = 37 + versionCode = 87 + versionName = "5.0.0-dev" + vectorDrawables.useSupportLibrary = true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + create("nfrelease") { + initWith(getByName("release")) + matchingFallbacks += "release" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + buildConfig = true + compose = true + } + + lint { + disable += "MissingTranslation" + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +dependencies { + implementation(project(":core")) + implementation(project(":sensorservices")) + implementation(project(":wearoslib")) + + implementation(libs.androidx.core.ktx) + implementation(libs.google.material) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.datastore.preferences) + + implementation(libs.play.services.wearable) + implementation(libs.play.services.location) + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(testFixtures(project(":wearoslib"))) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} + +hilt { + enableAggregatingTask = true +} diff --git a/build.gradle b/build.gradle deleted file mode 100644 index b2b5f13..0000000 --- a/build.gradle +++ /dev/null @@ -1,30 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - ext.kotlin_version = "1.9.0" - ext.hilt_version = '2.47' - repositories { - google() - mavenCentral() - } - dependencies { - classpath 'com.android.tools.build:gradle:8.2.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" - classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.7.7' - classpath 'com.google.gms:google-services:4.4.1' - classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' - classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1" - } - - -} - -allprojects { - repositories { - google() - mavenCentral() - maven { url "https://jitpack.io" } - } - - -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c953e63 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.oss.licenses) apply false +} + +subprojects { + plugins.withId("dev.detekt") { + extensions.configure { + buildUponDefaultConfig = true + config.setFrom(rootProject.files("config/detekt/detekt.yml")) + parallel = true + } + + dependencies { + add("detektPlugins", libs.detekt.rules.ktlint) + } + } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..eca7f15 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,165 @@ +# Rules here encode the parts of architecture.md that static analysis can verify. +# Package-layer dependencies and exact MVI stream shapes still require architectural tests. + +config: + validation: true + warningsAsErrors: false + +complexity: + CognitiveComplexMethod: + active: true + allowedComplexity: 12 + ComplexCondition: + active: true + allowedConditions: 4 + CyclomaticComplexMethod: + active: true + allowedComplexity: 12 + LargeClass: + active: true + allowedLines: 400 + LongMethod: + active: true + allowedLines: 60 + ignoreAnnotated: + - Composable + LongParameterList: + active: true + allowedFunctionParameters: 6 + allowedConstructorParameters: 8 + ignoreDefaultParameters: true + NestedBlockDepth: + active: true + allowedDepth: 4 + TooManyFunctions: + active: true + allowedFunctionsPerClass: 22 + allowedFunctionsPerFile: 22 + +coroutines: + CoroutineLaunchedInTestWithoutRunTest: + active: true + GlobalCoroutineUsage: + active: true + InjectDispatcher: + active: true + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunInFinallySection: + active: true + SuspendFunSwallowedCancellation: + active: true + SuspendFunWithCoroutineScopeReceiver: + active: true + SuspendFunWithFlowReturnType: + active: true + +exceptions: + ErrorUsageWithThrowable: + active: true + InstanceOfCheckForException: + active: true + NotImplementedDeclaration: + active: true + ObjectExtendsThrowable: + active: true + SwallowedException: + active: true + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionsWithoutMessageOrCause: + active: true + TooGenericExceptionCaught: + active: true + TooGenericExceptionThrown: + active: true + +naming: + FunctionNaming: + active: true + ignoreAnnotated: + - Composable + - Preview + PackageNaming: + active: true + packagePattern: '^[a-z]+(\.[a-z][a-zA-Z0-9]*)*$' + +potential-bugs: + CastNullableToNonNullableType: + active: true + DontDowncastCollectionTypes: + active: true + DoubleMutabilityForCollection: + active: true + ElseCaseInsteadOfExhaustiveWhen: + active: true + ExitOutsideMain: + active: true + ImplicitDefaultLocale: + active: true + NullCheckOnMutableProperty: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + +style: + DataClassShouldBeImmutable: + active: true + MagicNumber: + active: false + ForbiddenComment: + active: true + comments: + - 'FIXME:' + - 'STOPSHIP:' + ForbiddenImport: + active: true + forbiddenImports: + - 'kotlinx.coroutines.GlobalScope: Use a lifecycle-aware or injected CoroutineScope.' + ForbiddenMethodCall: + active: true + methods: + - kotlin.io.print + - kotlin.io.println + - java.lang.Thread.sleep + - androidx.compose.runtime.collectAsState + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MaxChainedCallsOnSameLine: + active: true + maxChainedCalls: 4 + MaxLineLength: + active: true + maxLineLength: 120 + MultilineLambdaItParameter: + active: true + ReturnCount: + active: true + max: 3 + ThrowsCount: + active: true + max: 2 + UnusedImport: + active: true + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateFunction: + active: true + ignoreAnnotated: + - Preview + UnusedPrivateProperty: + active: true + UnusedVariable: + active: true + VarCouldBeVal: + active: true + WildcardImport: + active: true diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..16d4218 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.detekt) +} + +android { + namespace = "com.motionapps.sensorbox.core" + compileSdk = 37 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testFixtures { + enable = true + } +} + +dependencies { + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.documentfile) + implementation(libs.coroutines.core) + + testFixturesImplementation(libs.coroutines.core) + testImplementation(libs.junit) +} diff --git a/gradle.properties b/gradle.properties index 9c2fbee..7cdc569 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects @@ -15,12 +15,11 @@ org.gradle.jvmargs=-Xmx2048m # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -kapt.incremental.apt=true -kapt.use.worker.api=false -android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false -android.nonFinalResIds=false +org.gradle.caching=true +org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..fa4ed51 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..cb823c2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,73 @@ +[versions] +agp = "9.3.1" +activityCompose = "1.13.0" +androidxTestJunit = "1.3.0" +composeBom = "2026.08.00" +core = "1.19.0" +coroutines = "1.11.0" +datastore = "1.2.1" +documentFile = "1.1.0" +detekt = "2.0.0-alpha.6" +espresso = "3.7.0" +hilt = "2.60.1" +kotlin = "2.4.10" +ksp = "2.3.10" +lifecycle = "2.11.0" +junit = "4.13.2" +material = "1.14.0" +navigation3 = "1.1.5" +ossLicensesPlugin = "0.13.0" +playServicesLocation = "21.4.0" +playServicesWearable = "20.0.1" +vico = "3.2.3" +wearable = "2.9.0" +wearCompose = "1.6.2" +wearRemoteInteractions = "1.2.0" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentFile" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wearCompose" } +androidx-wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearCompose" } +androidx-wear-remote-interactions = { module = "androidx.wear:wear-remote-interactions", version.ref = "wearRemoteInteractions" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +detekt-rules-ktlint = { module = "dev.detekt:detekt-rules-ktlint-wrapper", version.ref = "detekt" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +google-material = { module = "com.google.android.material:material", version.ref = "material" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +junit = { module = "junit:junit", version.ref = "junit" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } +play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" } +vico-compose = { module = "com.patrykandpatrick.vico:compose", version.ref = "vico" } +wearable = { module = "com.google.android.wearable:wearable", version.ref = "wearable" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +detekt = { id = "dev.detekt", version.ref = "detekt" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2547bdc..69dd0d0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,9 @@ -#Sun Sep 25 17:14:36 CEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/sensorservices/build.gradle b/sensorservices/build.gradle deleted file mode 100644 index ddfd3c7..0000000 --- a/sensorservices/build.gradle +++ /dev/null @@ -1,57 +0,0 @@ -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' - - -android { - compileSdk 34 - - defaultConfig { - minSdkVersion 24 - targetSdkVersion 34 - multiDexEnabled true - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles "consumer-rules.pro" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - nfrelease{ - initWith release - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - namespace 'com.motionapps.sensorservices' -} - -dependencies { - implementation fileTree(dir: "libs", include: ["*.jar"]) - - implementation project(path: ':wearoslib') - implementation project(path: ':flipper') - - implementation "androidx.multidex:multidex:2.0.1" - implementation 'androidx.core:core-ktx:1.10.1' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.gms:play-services-location:21.1.0' - implementation 'com.google.android.gms:play-services-maps:18.2.0' - implementation 'androidx.preference:preference-ktx:1.2.1' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' - - implementation 'com.jjoe64:graphview:4.2.2' - implementation 'com.github.GrenderG:Toasty:1.5.2' - -} \ No newline at end of file diff --git a/sensorservices/build.gradle.kts b/sensorservices/build.gradle.kts new file mode 100644 index 0000000..e6114af --- /dev/null +++ b/sensorservices/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.motionapps.sensorservices" + compileSdk = 37 + + defaultConfig { + minSdk = 24 + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + create("nfrelease") { + initWith(getByName("release")) + matchingFallbacks += "release" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + implementation(project(":core")) + implementation(project(":wearoslib")) + + implementation(libs.androidx.core.ktx) + implementation(libs.play.services.location) + implementation(libs.coroutines.core) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit) +} + +hilt { + enableAggregatingTask = true +} diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 481b7d2..0000000 --- a/settings.gradle +++ /dev/null @@ -1,7 +0,0 @@ -include ':flipper' -include ':wearoslib' -include ':wear' -include ':CountDownDialog' -include ':SensorServices' -include ':app' -rootProject.name = "SensorBox" \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ea7fb18 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,34 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "com.google.android.gms.oss-licenses-plugin") { + useModule("com.google.android.gms:oss-licenses-plugin:${requested.version}") + } + } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "SensorBox" + +include( + ":app", + ":core", + ":sensorservices", + ":wear", + ":wearoslib", +) + +project(":wearoslib").projectDir = file("WearOsLib") diff --git a/wear/build.gradle b/wear/build.gradle deleted file mode 100644 index 6433935..0000000 --- a/wear/build.gradle +++ /dev/null @@ -1,67 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'com.google.gms.google-services' -apply plugin: 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' -if (getGradle().getStartParameter().getTaskRequests().toString().contains("Release")) { - apply plugin: 'com.google.firebase.crashlytics' -} -android { - compileSdk 33 - defaultConfig { - applicationId "motionapps.sensorbox" - minSdkVersion 25 - targetSdkVersion 33 - versionCode 1000048 - versionName "3.5.2" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - signingConfig signingConfigs.debug - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - nfrelease{ - initWith release - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - namespace 'com.motionapps.sensorbox' -} -dependencies { - implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation project(path: ':wearoslib') - implementation project(path: ':SensorServices') - implementation 'androidx.core:core-ktx:1.10.1' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'androidx.wear:wear-remote-interactions:1.0.0' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - releaseImplementation 'com.google.firebase:firebase-analytics-ktx:21.5.1' - releaseImplementation 'com.google.firebase:firebase-crashlytics-ktx:18.6.2' - implementation 'androidx.percentlayout:percentlayout:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.3.1' - implementation 'androidx.wear:wear:1.3.0' - implementation 'androidx.preference:preference-ktx:1.2.1' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'com.google.android.support:wearable:2.9.0' - implementation 'com.google.android.gms:play-services-wearable:18.1.0' - implementation 'com.google.android.gms:play-services-location:21.1.0' - implementation 'com.google.android.gms:play-services-basement:18.3.0' - implementation 'com.google.android.gms:play-services-maps:18.2.0' - implementation 'com.google.android.material:material:1.9.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' - implementation 'com.github.GrenderG:Toasty:1.5.2' - implementation 'com.jjoe64:graphview:4.2.2' - compileOnly 'com.google.android.wearable:wearable:2.9.0' -} \ No newline at end of file diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 0000000..a299b12 --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,94 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.motionapps.sensorbox" + compileSdk = 37 + + defaultConfig { + applicationId = "motionapps.sensorbox" + minSdk = 26 + targetSdk = 37 + versionCode = 1000049 + versionName = "4.0.0-dev" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + create("nfrelease") { + initWith(getByName("release")) + matchingFallbacks += "release" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +dependencies { + implementation(project(":core")) + implementation(project(":wearoslib")) + implementation(project(":sensorservices")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.wear.remote.interactions) + implementation(libs.play.services.wearable) + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.wear.compose.foundation) + implementation(libs.androidx.wear.compose.material3) + implementation(libs.vico.compose) + + compileOnly(libs.wearable) + + testImplementation(libs.junit) + testImplementation(testFixtures(project(":core"))) + testImplementation(testFixtures(project(":wearoslib"))) + testImplementation(libs.coroutines.test) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(testFixtures(project(":wearoslib"))) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} + +hilt { + enableAggregatingTask = true +} From 6cade2f9290275ee4ad86333aa4247cc5e75d457 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Sat, 15 Aug 2026 23:02:32 +0200 Subject: [PATCH 03/32] refactor(wearlib): add typed connectivity and protocol APIs --- .../GooglePlayWearConnectionRepository.kt | 72 ++++++++++ .../wearoslib/connectivity/WearConnection.kt | 9 ++ .../connectivity/WearConnectionModule.kt | 15 ++ .../connectivity/WearConnectionRepository.kt | 11 ++ .../connectivity/WearConnectionUseCases.kt | 16 +++ .../connectivity/WearNodeSelector.kt | 6 + .../files/GooglePlayWearFileTransferClient.kt | 57 ++++++++ .../wearoslib/files/WearFileMetadata.kt | 3 + .../wearoslib/files/WearFilePathCodec.kt | 46 ++++++ .../wearoslib/files/WearFileTransferClient.kt | 10 ++ .../wearoslib/files/WearFileTransferModule.kt | 15 ++ .../wearoslib/protocol/WearCommand.kt | 24 ++++ .../wearoslib/protocol/WearCommandCodec.kt | 136 ++++++++++++++++++ .../protocol/WearSensorCatalogStore.kt | 21 +++ .../motionapps/wearoslib/ExampleUnitTest.kt | 17 --- .../connectivity/WearNodeSelectorTest.kt | 28 ++++ .../wearoslib/files/WearFilePathCodecTest.kt | 25 ++++ .../protocol/WearCommandCodecTest.kt | 48 +++++++ .../FakeWearConnectionRepository.kt | 41 ++++++ .../files/WearSyncEmulatorFixture.kt | 11 ++ 20 files changed, 594 insertions(+), 17 deletions(-) create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnection.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionModule.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearNodeSelector.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileMetadata.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferModule.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt create mode 100644 WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt delete mode 100644 WearOsLib/src/test/java/com/motionapps/wearoslib/ExampleUnitTest.kt create mode 100644 WearOsLib/src/test/java/com/motionapps/wearoslib/connectivity/WearNodeSelectorTest.kt create mode 100644 WearOsLib/src/test/java/com/motionapps/wearoslib/files/WearFilePathCodecTest.kt create mode 100644 WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt create mode 100644 WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt create mode 100644 WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/files/WearSyncEmulatorFixture.kt diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt new file mode 100644 index 0000000..5e123db --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt @@ -0,0 +1,72 @@ +package com.motionapps.wearoslib.connectivity + +import android.content.Context +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.Node +import com.google.android.gms.wearable.Wearable +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.suspendAppResult +import com.motionapps.sensorbox.core.error.suspendFlatMap +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext context: Context) : + WearConnectionRepository { + private val capabilityClient = Wearable.getCapabilityClient(context) + private val messageClient = Wearable.getMessageClient(context) + + override fun observeCapability(capability: String): Flow = callbackFlow { + val listener = CapabilityClient.OnCapabilityChangedListener { info -> + trySend(info.toConnection()) + } + capabilityClient.addListener(listener, capability).await() + trySend(loadConnection(capability)) + awaitClose { capabilityClient.removeListener(listener) } + }.catch { error -> + AppError.from(AppError.Kind.CONNECTIVITY, "Observe Wear connection", error) + emit(WearConnection.Disconnected) + } + + override suspend fun findNode(capability: String): WearNode? { + val info = capabilityClient + .getCapability(capability, CapabilityClient.FILTER_REACHABLE) + .await() + return WearNodeSelector.select(info.nodes.map { it.toWearNode() }) + } + + override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): Result = + suspendAppResult(AppError.Kind.CONNECTIVITY, "Find Wear node") { findNode(capability) } + .suspendFlatMap { node -> + if (node == null) { + Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Find reachable Wear node for $capability")) + } else { + suspendAppResult(AppError.Kind.CONNECTIVITY, "Send Wear message") { + messageClient.sendMessage(node.id, path, payload).await() + Unit + } + } + } + + private suspend fun loadConnection(capability: String): WearConnection = findNode(capability) + ?.let(WearConnection::Connected) + ?: WearConnection.Disconnected + + private fun CapabilityInfo.toConnection(): WearConnection = WearNodeSelector + .select(nodes.map { it.toWearNode() }) + ?.let(WearConnection::Connected) + ?: WearConnection.Disconnected + + private fun Node.toWearNode() = WearNode( + id = id, + displayName = displayName, + isNearby = isNearby, + ) +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnection.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnection.kt new file mode 100644 index 0000000..3e754e1 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnection.kt @@ -0,0 +1,9 @@ +package com.motionapps.wearoslib.connectivity + +data class WearNode(val id: String, val displayName: String, val isNearby: Boolean) + +sealed interface WearConnection { + data object Disconnected : WearConnection + + data class Connected(val node: WearNode) : WearConnection +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionModule.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionModule.kt new file mode 100644 index 0000000..e82a9c0 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionModule.kt @@ -0,0 +1,15 @@ +package com.motionapps.wearoslib.connectivity + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class WearConnectionModule { + @Binds + @Singleton + abstract fun bindWearConnectionRepository(repository: GooglePlayWearConnectionRepository): WearConnectionRepository +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt new file mode 100644 index 0000000..f04a0cf --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt @@ -0,0 +1,11 @@ +package com.motionapps.wearoslib.connectivity + +import kotlinx.coroutines.flow.Flow + +interface WearConnectionRepository { + fun observeCapability(capability: String): Flow + + suspend fun findNode(capability: String): WearNode? + + suspend fun sendMessage(capability: String, path: String, payload: ByteArray): Result +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt new file mode 100644 index 0000000..d884c24 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt @@ -0,0 +1,16 @@ +package com.motionapps.wearoslib.connectivity + +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class ObserveWearCapabilityUseCase @Inject constructor(private val repository: WearConnectionRepository) { + operator fun invoke(capability: String): Flow = repository.observeCapability(capability) +} + +class SendWearMessageUseCase @Inject constructor(private val repository: WearConnectionRepository) { + suspend operator fun invoke(capability: String, path: String, message: String): Result = + invoke(capability, path, message.encodeToByteArray()) + + suspend operator fun invoke(capability: String, path: String, payload: ByteArray): Result = + repository.sendMessage(capability, path, payload) +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearNodeSelector.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearNodeSelector.kt new file mode 100644 index 0000000..9453942 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearNodeSelector.kt @@ -0,0 +1,6 @@ +package com.motionapps.wearoslib.connectivity + +internal object WearNodeSelector { + fun select(nodes: Collection): WearNode? = + nodes.firstOrNull(WearNode::isNearby) ?: nodes.minByOrNull(WearNode::id) +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt new file mode 100644 index 0000000..b5fef8b --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt @@ -0,0 +1,57 @@ +package com.motionapps.wearoslib.files + +import android.content.Context +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.Wearable +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.combineAppResults +import com.motionapps.sensorbox.core.error.suspendAppResult +import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.sensorbox.core.error.withAppError +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext context: Context) : + WearFileTransferClient { + private val channelClient = Wearable.getChannelClient(context) + + override suspend fun send( + nodeId: String, + metadata: WearFileMetadata, + input: () -> java.io.InputStream, + ): Result = withContext(Dispatchers.IO) { + WearFilePathCodec.encode(metadata).suspendFlatMap { path -> + suspendAppResult(AppError.Kind.CONNECTIVITY, "Open Wear channel") { + channelClient.openChannel(nodeId, path).await() + } + }.suspendFlatMap { channel -> + val transfer = suspendAppResult(AppError.Kind.CONNECTIVITY, "Write Wear channel") { + input().use { source -> + channelClient.getOutputStream(channel).await().use(source::copyTo) + } + } + val close = suspendAppResult(AppError.Kind.CONNECTIVITY, "Close Wear channel") { + channelClient.close(channel).await() + } + listOf(transfer, close).combineAppResults(AppError.Kind.CONNECTIVITY, "Send Wear file") + }.withAppError(AppError.Kind.CONNECTIVITY, "Send Wear file") + } + + override suspend fun receive( + channel: ChannelClient.Channel, + consume: (java.io.InputStream) -> Result, + ): Result = withContext(Dispatchers.IO) { + val transfer = suspendAppResult(AppError.Kind.CONNECTIVITY, "Open Wear input stream") { + channelClient.getInputStream(channel).await() + }.suspendFlatMap { input -> + input.use(consume) + } + val close = suspendAppResult(AppError.Kind.CONNECTIVITY, "Close Wear channel") { + channelClient.close(channel).await() + } + listOf(transfer, close).combineAppResults(AppError.Kind.CONNECTIVITY, "Receive Wear file") + } +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileMetadata.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileMetadata.kt new file mode 100644 index 0000000..7bc8e7b --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileMetadata.kt @@ -0,0 +1,3 @@ +package com.motionapps.wearoslib.files + +data class WearFileMetadata(val measurementName: String, val fileName: String) diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt new file mode 100644 index 0000000..4f63275 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt @@ -0,0 +1,46 @@ +package com.motionapps.wearoslib.files + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.core.error.flatMap +import java.util.Base64 + +object WearFilePathCodec { + fun encode(metadata: WearFileMetadata): Result = if ( + metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart() + ) { + appResult(AppError.Kind.CONNECTIVITY, "Encode Wear file path") { + "$PREFIX/${metadata.measurementName.encodePart()}/${metadata.fileName.encodePart()}" + } + } else { + Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + } + + fun decode(path: String): Result { + val parts = path.removePrefix("$PREFIX/").split('/') + if (!path.startsWith("$PREFIX/") || parts.size != 2) { + return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + } + return appResult(AppError.Kind.CONNECTIVITY, "Decode Wear file path") { + WearFileMetadata(parts[0].decodePart(), parts[1].decodePart()) + }.flatMap { metadata -> + if (metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart()) { + Result.success(metadata) + } else { + Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + } + } + } + + private fun String.encodePart(): String = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(encodeToByteArray()) + + private fun String.decodePart(): String = Base64.getUrlDecoder().decode(this).decodeToString() + + private fun String.isSafePathPart(): Boolean = + isNotBlank() && length <= MAX_PART_LENGTH && '/' !in this && '\\' !in this && this != "." && this != ".." + + const val PREFIX = "/sensorbox/v1/file" + private const val MAX_PART_LENGTH = 120 +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt new file mode 100644 index 0000000..5dd106b --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt @@ -0,0 +1,10 @@ +package com.motionapps.wearoslib.files + +import com.google.android.gms.wearable.ChannelClient +import java.io.InputStream + +interface WearFileTransferClient { + suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): Result + + suspend fun receive(channel: ChannelClient.Channel, consume: (InputStream) -> Result): Result +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferModule.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferModule.kt new file mode 100644 index 0000000..ef58214 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferModule.kt @@ -0,0 +1,15 @@ +package com.motionapps.wearoslib.files + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class WearFileTransferModule { + @Binds + @Singleton + abstract fun bindWearFileTransferClient(implementation: GooglePlayWearFileTransferClient): WearFileTransferClient +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt new file mode 100644 index 0000000..c2a18cc --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt @@ -0,0 +1,24 @@ +package com.motionapps.wearoslib.protocol + +sealed interface WearCommand { + data object LaunchPhone : WearCommand + + data class StartMeasurement( + val folderName: String, + val sensorIds: List, + val includesGps: Boolean, + val startAtEpochMillis: Long = System.currentTimeMillis(), + val durationMillis: Long = 0L, + val measurementType: String = "ENDLESS", + ) : WearCommand + + data object StopMeasurement : WearCommand + + data object SyncMeasurements : WearCommand + + data object RequestSensorList : WearCommand + + data class SensorList(val sensors: List) : WearCommand +} + +data class WearSensorInfo(val type: Int, val name: String, val vendor: String, val isHeartRate: Boolean) diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt new file mode 100644 index 0000000..e6311a9 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt @@ -0,0 +1,136 @@ +package com.motionapps.wearoslib.protocol + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.core.error.flatMap +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream + +object WearCommandCodec { + fun encode(command: WearCommand): Result { + val itemCount = when (command) { + is WearCommand.SensorList -> command.sensors.size + is WearCommand.StartMeasurement -> command.sensorIds.size + else -> 0 + } + if (itemCount > MAX_SENSORS) { + return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command")) + } + return appResult(AppError.Kind.CONNECTIVITY, "Encode Wear command") { + ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use { output -> + output.writeInt(MAGIC) + output.writeByte(VERSION) + output.writeCommand(command) + } + bytes.toByteArray() + } + } + } + + fun decode(payload: ByteArray): Result = appResult(AppError.Kind.CONNECTIVITY, "Read Wear command") { + DataInputStream(ByteArrayInputStream(payload)).use { input -> + val validHeader = input.readInt() == MAGIC && input.readUnsignedByte() == VERSION + if (validHeader) { + input.readCommand() + } else { + Result.failure( + AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command header"), + ) + } + } + }.flatMap { it } + + private fun DataOutputStream.writeCommand(command: WearCommand) { + when (command) { + WearCommand.LaunchPhone -> writeByte(TYPE_LAUNCH_PHONE) + is WearCommand.StartMeasurement -> writeMeasurement(command) + WearCommand.StopMeasurement -> writeByte(TYPE_STOP_MEASUREMENT) + WearCommand.SyncMeasurements -> writeByte(TYPE_SYNC_MEASUREMENTS) + WearCommand.RequestSensorList -> writeByte(TYPE_REQUEST_SENSOR_LIST) + is WearCommand.SensorList -> writeSensorList(command) + } + } + + private fun DataOutputStream.writeSensorList(command: WearCommand.SensorList) { + writeByte(TYPE_SENSOR_LIST) + writeByte(command.sensors.size) + command.sensors.forEach { sensor -> + writeInt(sensor.type) + writeUTF(sensor.name.take(MAX_SENSOR_TEXT_LENGTH)) + writeUTF(sensor.vendor.take(MAX_SENSOR_TEXT_LENGTH)) + writeBoolean(sensor.isHeartRate) + } + } + + private fun DataOutputStream.writeMeasurement(command: WearCommand.StartMeasurement) { + writeByte(TYPE_START_MEASUREMENT) + writeUTF(command.folderName.take(MAX_FOLDER_LENGTH)) + writeBoolean(command.includesGps) + writeByte(command.sensorIds.size) + command.sensorIds.forEach(::writeInt) + writeLong(command.startAtEpochMillis) + writeLong(command.durationMillis) + writeUTF(command.measurementType.take(MAX_TYPE_LENGTH)) + } + + private fun DataInputStream.readCommand(): Result = when (readUnsignedByte()) { + TYPE_LAUNCH_PHONE -> Result.success(WearCommand.LaunchPhone) + TYPE_START_MEASUREMENT -> readMeasurement() + TYPE_STOP_MEASUREMENT -> Result.success(WearCommand.StopMeasurement) + TYPE_SYNC_MEASUREMENTS -> Result.success(WearCommand.SyncMeasurements) + TYPE_REQUEST_SENSOR_LIST -> Result.success(WearCommand.RequestSensorList) + TYPE_SENSOR_LIST -> readSensorList() + else -> Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command type")) + } + + private fun DataInputStream.readSensorList(): Result { + val count = readUnsignedByte() + if (count > MAX_SENSORS) { + return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear sensor count")) + } + return Result.success( + WearCommand.SensorList( + List(count) { WearSensorInfo(readInt(), readUTF(), readUTF(), readBoolean()) }, + ), + ) + } + + private fun DataInputStream.readMeasurement(): Result { + val folderName = readUTF() + val includesGps = readBoolean() + val sensorCount = readUnsignedByte() + if (sensorCount > MAX_SENSORS) { + return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear sensor count")) + } + val sensorIds = List(sensorCount) { readInt() } + val startAt = if (available() >= Long.SIZE_BYTES) readLong() else System.currentTimeMillis() + val duration = if (available() >= Long.SIZE_BYTES) readLong() else 0L + val type = if (available() > 0) readUTF() else "ENDLESS" + return Result.success( + WearCommand.StartMeasurement( + folderName = folderName, + sensorIds = sensorIds, + includesGps = includesGps, + startAtEpochMillis = startAt, + durationMillis = duration, + measurementType = type, + ), + ) + } + + private const val MAGIC = 0x53425831 + private const val VERSION = 1 + private const val TYPE_LAUNCH_PHONE = 1 + private const val TYPE_START_MEASUREMENT = 2 + private const val TYPE_STOP_MEASUREMENT = 3 + private const val TYPE_SYNC_MEASUREMENTS = 4 + private const val TYPE_REQUEST_SENSOR_LIST = 5 + private const val TYPE_SENSOR_LIST = 6 + private const val MAX_SENSORS = 64 + private const val MAX_FOLDER_LENGTH = 100 + private const val MAX_TYPE_LENGTH = 32 + private const val MAX_SENSOR_TEXT_LENGTH = 100 +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt new file mode 100644 index 0000000..55c2e72 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt @@ -0,0 +1,21 @@ +package com.motionapps.wearoslib.protocol + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WearSensorCatalogStore @Inject constructor() { + private val mutableSensors = MutableStateFlow>(emptyList()) + val sensors: StateFlow> = mutableSensors.asStateFlow() + + fun update(sensors: List) { + mutableSensors.value = sensors.distinctBy(WearSensorInfo::type).sortedBy(WearSensorInfo::name) + } + + fun clear() { + mutableSensors.value = emptyList() + } +} diff --git a/WearOsLib/src/test/java/com/motionapps/wearoslib/ExampleUnitTest.kt b/WearOsLib/src/test/java/com/motionapps/wearoslib/ExampleUnitTest.kt deleted file mode 100644 index ddb3556..0000000 --- a/WearOsLib/src/test/java/com/motionapps/wearoslib/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.motionapps.wearoslib - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/WearOsLib/src/test/java/com/motionapps/wearoslib/connectivity/WearNodeSelectorTest.kt b/WearOsLib/src/test/java/com/motionapps/wearoslib/connectivity/WearNodeSelectorTest.kt new file mode 100644 index 0000000..a0b3bb6 --- /dev/null +++ b/WearOsLib/src/test/java/com/motionapps/wearoslib/connectivity/WearNodeSelectorTest.kt @@ -0,0 +1,28 @@ +package com.motionapps.wearoslib.connectivity + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WearNodeSelectorTest { + @Test + fun `Given nearby and remote nodes When selecting Then nearby node is returned`() { + val givenNearby = WearNode("nearby", "Watch", isNearby = true) + val givenRemote = WearNode("remote", "Watch backup", isNearby = false) + + val whenSelected = WearNodeSelector.select(listOf(givenRemote, givenNearby)) + + assertEquals(givenNearby, whenSelected) + } + + @Test + fun `Given remote nodes When selecting Then deterministic node is returned`() { + val givenNodes = listOf( + WearNode("z-node", "Second", isNearby = false), + WearNode("a-node", "First", isNearby = false), + ) + + val whenSelected = WearNodeSelector.select(givenNodes) + + assertEquals("a-node", whenSelected?.id) + } +} diff --git a/WearOsLib/src/test/java/com/motionapps/wearoslib/files/WearFilePathCodecTest.kt b/WearOsLib/src/test/java/com/motionapps/wearoslib/files/WearFilePathCodecTest.kt new file mode 100644 index 0000000..2b35f59 --- /dev/null +++ b/WearOsLib/src/test/java/com/motionapps/wearoslib/files/WearFilePathCodecTest.kt @@ -0,0 +1,25 @@ +package com.motionapps.wearoslib.files + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearFilePathCodecTest { + @Test + fun `Given safe metadata When encoded and decoded Then names survive`() { + val given = WearFileMetadata("recording_2026-08-13_12-30-00", "heart_rate.csv") + + val actual = WearFilePathCodec.decode(WearFilePathCodec.encode(given).getOrThrow()) + + assertEquals(given, actual.getOrThrow()) + } + + @Test + fun `Given a traversal path When decoded Then it is rejected`() { + val unsafePath = "${WearFilePathCodec.PREFIX}/Li4/file" + + val actual = WearFilePathCodec.decode(unsafePath) + + assertTrue(actual.isFailure) + } +} diff --git a/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt new file mode 100644 index 0000000..e465931 --- /dev/null +++ b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt @@ -0,0 +1,48 @@ +package com.motionapps.wearoslib.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearCommandCodecTest { + @Test + fun `Given a measurement command When encoded and decoded Then all fields survive`() { + val given = WearCommand.StartMeasurement("session", listOf(1, 4, 21), includesGps = true) + + val actual = WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()) + + assertEquals(given, actual.getOrThrow()) + } + + @Test + fun `Given an unknown payload When decoded Then it is rejected`() { + val invalidPayload = byteArrayOf(1, 2, 3) + + val actual = WearCommandCodec.decode(invalidPayload) + + assertTrue(actual.isFailure) + } + + @Test + fun `Given a synchronized measurement When encoded Then schedule survives`() { + val given = WearCommand.StartMeasurement( + folderName = "shared_session", + sensorIds = listOf(1, 21), + includesGps = false, + startAtEpochMillis = 1_800_000_000_000L, + durationMillis = 45_000L, + measurementType = "TIMED", + ) + + assertEquals(given, WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()).getOrThrow()) + } + + @Test + fun `Given a Wear sensor catalogue When encoded and decoded Then descriptors survive`() { + val given = WearCommand.SensorList( + listOf(WearSensorInfo(21, "Heart rate", "Fixture", isHeartRate = true)), + ) + + assertEquals(given, WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()).getOrThrow()) + } +} diff --git a/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt b/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt new file mode 100644 index 0000000..6a2025e --- /dev/null +++ b/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt @@ -0,0 +1,41 @@ +package com.motionapps.wearoslib.connectivity + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeWearConnectionRepository( + initialConnection: WearConnection = WearConnection.Disconnected, +) : WearConnectionRepository { + private val connection = MutableStateFlow(initialConnection) + private var sendResult: Result = Result.success(Unit) + + val sentMessages = mutableListOf() + + override fun observeCapability(capability: String): Flow = connection + + override suspend fun findNode(capability: String): WearNode? = + (connection.value as? WearConnection.Connected)?.node + + override suspend fun sendMessage( + capability: String, + path: String, + payload: ByteArray, + ): Result { + sentMessages += SentWearMessage(capability, path, payload.copyOf()) + return sendResult + } + + fun emit(newConnection: WearConnection) { + connection.value = newConnection + } + + fun failSending(error: Throwable) { + sendResult = Result.failure(error) + } +} + +data class SentWearMessage( + val capability: String, + val path: String, + val payload: ByteArray, +) diff --git a/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/files/WearSyncEmulatorFixture.kt b/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/files/WearSyncEmulatorFixture.kt new file mode 100644 index 0000000..1a578e7 --- /dev/null +++ b/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/files/WearSyncEmulatorFixture.kt @@ -0,0 +1,11 @@ +package com.motionapps.wearoslib.files + +object WearSyncEmulatorFixture { + const val APP_DIRECTORY = "SensorBox" + const val MEASUREMENT_NAME = "EMULATOR_SYNC_TEST" + const val RECEIVED_MEASUREMENT_NAME = "WEAR_$MEASUREMENT_NAME" + const val FILE_NAME = "accelerometer.csv" + const val CONTENT = + "t_sensor;t_unix;x;y;z;accuracy\n" + + "123;456;1.0;2.0;3.0;3\n" +} From a998ea7e2b73ff107fefca40b43872d2e61726b0 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Sat, 15 Aug 2026 23:02:32 +0200 Subject: [PATCH 04/32] refactor(sensorservice): introduce measurement foundations --- .../sensorservices/ExampleInstrumentedTest.kt | 24 -- sensorservices/src/main/AndroidManifest.xml | 15 +- .../sensorservices/handlers/GPSHandler.kt | 127 +++---- .../sensorservices/handlers/PoweManagement.kt | 60 ---- .../sensorservices/handlers/StorageHandler.kt | 334 +++++------------- .../intent/MeasurementIntentFactory.kt | 43 +++ .../intent/MeasurementLaunchRequest.kt | 22 ++ .../session/MeasurementSessionState.kt | 14 + .../session/MeasurementSessionStore.kt | 25 ++ .../sensorservices/types/EndHolder.kt | 9 - .../sensorservices/types/SensorHolder.kt | 192 +++------- .../sensorservices/types/SensorSpec.kt | 48 +++ .../sensorservices/types/SensorsNeeds.kt | 147 -------- sensorservices/src/main/res/raw/alert.wav | Bin 418996 -> 0 bytes sensorservices/src/main/res/raw/beep.wav | Bin 32334 -> 0 bytes sensorservices/src/main/res/raw/end.wav | Bin 182550 -> 0 bytes sensorservices/src/main/res/raw/start.wav | Bin 407700 -> 0 bytes .../src/main/res/values-v34/strings.xml | 45 --- sensorservices/src/main/res/values/colors.xml | 4 - .../src/main/res/values/strings.xml | 48 +-- .../sensorservices/ExampleUnitTest.kt | 17 - .../types/SensorHolderErrorTest.kt | 28 ++ .../sensorservices/types/SensorSpecTest.kt | 30 ++ 23 files changed, 431 insertions(+), 801 deletions(-) delete mode 100644 sensorservices/src/androidTest/java/com/motionapps/sensorservices/ExampleInstrumentedTest.kt delete mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/handlers/PoweManagement.kt create mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt create mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt create mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt create mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt delete mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/types/EndHolder.kt create mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorSpec.kt delete mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorsNeeds.kt delete mode 100644 sensorservices/src/main/res/raw/alert.wav delete mode 100644 sensorservices/src/main/res/raw/beep.wav delete mode 100644 sensorservices/src/main/res/raw/end.wav delete mode 100644 sensorservices/src/main/res/raw/start.wav delete mode 100644 sensorservices/src/main/res/values-v34/strings.xml delete mode 100644 sensorservices/src/main/res/values/colors.xml delete mode 100644 sensorservices/src/test/java/com/motionapps/sensorservices/ExampleUnitTest.kt create mode 100644 sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt create mode 100644 sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorSpecTest.kt diff --git a/sensorservices/src/androidTest/java/com/motionapps/sensorservices/ExampleInstrumentedTest.kt b/sensorservices/src/androidTest/java/com/motionapps/sensorservices/ExampleInstrumentedTest.kt deleted file mode 100644 index c5ca8c9..0000000 --- a/sensorservices/src/androidTest/java/com/motionapps/sensorservices/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.motionapps.sensorservices - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.motionapps.sensorservices.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/sensorservices/src/main/AndroidManifest.xml b/sensorservices/src/main/AndroidManifest.xml index 2ca7577..632668c 100644 --- a/sensorservices/src/main/AndroidManifest.xml +++ b/sensorservices/src/main/AndroidManifest.xml @@ -4,13 +4,18 @@ + + + + - - + + + - - - \ No newline at end of file + 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 fa957d4..a42ac7f 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt @@ -2,19 +2,21 @@ package com.motionapps.sensorservices.handlers import android.annotation.SuppressLint import android.content.Context -import android.content.SharedPreferences import android.location.Location import android.os.Looper import android.util.Log -import androidx.preference.PreferenceManager -import com.google.android.gms.location.* -import com.motionapps.sensorservices.services.MeasurementService -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.InternalCoroutinesApi +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.Granularity +import com.google.android.gms.location.LocationAvailability +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 com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.core.error.flatMap - -@ExperimentalCoroutinesApi -@InternalCoroutinesApi @SuppressLint("MissingPermission") class GPSHandler : LocationCallback() { @@ -27,6 +29,8 @@ class GPSHandler : LocationCallback() { private var registered: Boolean = false private var firstInit: Boolean = false + private var intervalSeconds: Int = DEFAULT_INTERVAL_SECONDS + private var minDistanceMeters: Int = DEFAULT_DISTANCE_METERS private val tag = "GPS_location" /** @@ -34,8 +38,8 @@ class GPSHandler : LocationCallback() { * * @param context */ - private fun firstInit(context: Context){ - request = createRequest(context) + private fun firstInit(context: Context) { + request = createRequest() locationClient = LocationServices.getFusedLocationProviderClient(context) firstInit = true } @@ -45,25 +49,26 @@ class GPSHandler : LocationCallback() { * * @param context */ - private fun initialize(context: Context){ - - if(!firstInit){ + private fun initialize(context: Context) { + if (!firstInit) { firstInit(context) } locationClient.lastLocation.addOnSuccessListener { location: Location? -> if (location == null) { - callback!!.onLastLocationSuccess(null) + callback?.onLastLocationSuccess(null) } else { lastLocation = location callback?.onLastLocationSuccess(location) } - - }.addOnFailureListener { - callback!!.onLastLocationSuccess(null) + }.addOnFailureListener { error -> + AppError.from(AppError.Kind.MEASUREMENT, "Read last GPS location", error) + callback?.onLastLocationSuccess(null) } - locationClient.requestLocationUpdates(request, this, Looper.getMainLooper()) + locationClient.requestLocationUpdates(request, this, Looper.getMainLooper()).addOnFailureListener { error -> + AppError.from(AppError.Kind.MEASUREMENT, "Request GPS updates", error) + } registered = true } @@ -74,7 +79,7 @@ class GPSHandler : LocationCallback() { */ override fun onLocationResult(locationResult: LocationResult) { super.onLocationResult(locationResult) - if (locationResult.locations.size > 0) { + if (locationResult.locations.isNotEmpty()) { lastLocation = locationResult.lastLocation if (lastLocation != null) { callback?.onLocationChanged(lastLocation) @@ -82,51 +87,43 @@ class GPSHandler : LocationCallback() { } } - /** - * changes if the location cahnges provider / GPS is off - * - * @param locationAvailability - */ + /** Reports provider availability changes to the active measurement. */ override fun onLocationAvailability(locationAvailability: LocationAvailability) { super.onLocationAvailability(locationAvailability) this.locationAvailability = locationAvailability callback?.onAvailabilityChanged(locationAvailability) } - /** - * removes GPS - no updates will be passed - * - */ - fun gpsOff() { - if(registered){ + /** Stops location updates for the active measurement. */ + fun gpsOff(): Result = appResult(AppError.Kind.MEASUREMENT, "Stop GPS updates") { + if (registered) { Log.i(tag, "Logging off location") - locationClient.flushLocations() - locationClient.removeLocationUpdates(this) + locationClient.flushLocations().addOnFailureListener { error -> + AppError.from(AppError.Kind.MEASUREMENT, "Flush GPS updates", error) + } + locationClient.removeLocationUpdates(this).addOnFailureListener { error -> + AppError.from(AppError.Kind.MEASUREMENT, "Remove GPS updates", error) + } } registered = false } - /** - * parameters for locationClient - received from sharedPreferences - user can change them in Settings - * - * @param context - * @return LocationRequest specified by user - */ - private fun createRequest(context: Context): LocationRequest { - val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - var b: LocationRequest.Builder - try { - b = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, sharedPreferences.getString(MeasurementService.GPS_TIME, "10")!!.toLong() * 1000L) - b.setMinUpdateDistanceMeters(sharedPreferences.getString(MeasurementService.GPS_DISTANCE, "20")!!.toFloat()) - }catch (e: ClassCastException){ - b = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, sharedPreferences.getInt(MeasurementService.GPS_TIME, 10) * 1000L) - b.setMinUpdateDistanceMeters(sharedPreferences.getInt(MeasurementService.GPS_DISTANCE, 20).toFloat()) - } - b.setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL) - b.setWaitForAccurateLocation(true) - //registering GPS + /** Creates a request from the immutable measurement configuration. */ + private fun createRequest(): LocationRequest { + val builder = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + intervalSeconds * 1000L, + ) + builder.setMinUpdateDistanceMeters(minDistanceMeters.toFloat()) + builder.setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL) + builder.setWaitForAccurateLocation(true) Log.i("GPS", "location request created") - return b.build() + return builder.build() + } + + fun configure(intervalSeconds: Int, minDistanceMeters: Int) { + this.intervalSeconds = intervalSeconds.coerceIn(1, MAX_INTERVAL_SECONDS) + this.minDistanceMeters = minDistanceMeters.coerceIn(0, MAX_DISTANCE_METERS) } /** @@ -136,19 +133,25 @@ class GPSHandler : LocationCallback() { * @param gpsCallback - this object will get access to location and updates, previous is forgotten * */ - fun addCallback(context: Context, gpsCallback: OnLocationChangedCallback) { - if(registered){ - gpsOff() + fun addCallback(context: Context, gpsCallback: OnLocationChangedCallback): Result = + (if (registered) gpsOff() else Result.success(Unit)).flatMap { + appResult(AppError.Kind.MEASUREMENT, "Register GPS callback") { + callback = gpsCallback + initialize(context) + gpsCallback.onLocationChanged(lastLocation) + } } - initialize(context) - callback = gpsCallback - gpsCallback.onLocationChanged(lastLocation) - } - interface OnLocationChangedCallback { fun onLocationChanged(location: Location?) fun onLastLocationSuccess(location: Location?) fun onAvailabilityChanged(locationAvailability: LocationAvailability?) } -} \ No newline at end of file + + private companion object { + const val DEFAULT_INTERVAL_SECONDS = 10 + const val DEFAULT_DISTANCE_METERS = 20 + const val MAX_INTERVAL_SECONDS = 3_600 + const val MAX_DISTANCE_METERS = 10_000 + } +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/PoweManagement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/PoweManagement.kt deleted file mode 100644 index f07d73c..0000000 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/PoweManagement.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.motionapps.sensorservices.handlers - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.PowerManager -import android.provider.Settings -import androidx.annotation.RequiresApi -import com.motionapps.sensorservices.R -import es.dmoral.toasty.Toasty - -object PowerManagement { - - /** - * request to add app to whitelist - * - */ - @RequiresApi(Build.VERSION_CODES.M) - @SuppressLint("BatteryLife") - fun tryToIgnoreBatteryOptimisations(context: Context, withIcon: Boolean = true) { - - val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager? - if (pm == null) { - Toasty.error( - context, - context.getString(R.string.intro_ignored_optimisations_error), - Toasty.LENGTH_LONG, - withIcon - ).show() - return - } - - if (pm.isIgnoringBatteryOptimizations(context.packageName)) { - Toasty.success( - context, - context.getString(R.string.intro_ignored_optimisations), - Toasty.LENGTH_LONG, - withIcon - ).show() - } - - val intent = Intent() - intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - intent.data = Uri.parse("package:" + context.packageName) - - try { - context.startActivity(intent) - } catch (e: Exception) { - Toasty.error( - context, - context.getString(R.string.intro_ignored_optimisations_error), - Toasty.LENGTH_LONG, - withIcon - ).show() - } - } - -} 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 c230a18..7428763 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt @@ -1,285 +1,115 @@ package com.motionapps.sensorservices.handlers -import android.Manifest import android.content.Context import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat -import androidx.documentfile.provider.DocumentFile -import com.balda.flipper.DocumentFileCompat -import com.balda.flipper.OperationFailedException -import com.balda.flipper.Root -import com.balda.flipper.StorageManagerCompat +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.core.error.flatMap +import com.motionapps.sensorbox.core.storage.NativeDocumentStorage import com.motionapps.sensorservices.R -import es.dmoral.toasty.Toasty import java.io.File import java.io.FileOutputStream -import java.io.IOException import java.io.OutputStream import java.text.SimpleDateFormat -import java.util.* +import java.util.Calendar +import java.util.Locale -/** - * Storage functions to create, delete folders and files - */ +/** Native storage operations shared by the foreground measurement service. */ object StorageHandler { - /** - * changes milliseconds from the beginning of the epoch to string based on the formatting - * - * @param milliSeconds - System.currentMillis() - * @param stringFormat - "dd. MM. yyyy HH:mm:ss" - * @return string with formatted date - */ - fun getDate(milliSeconds: Long, stringFormat: String ="dd. MM. yyyy HH:mm:ss"): String { - val formatter = SimpleDateFormat(stringFormat, Locale.getDefault()) - val calendar: Calendar = Calendar.getInstance() - calendar.timeInMillis = milliSeconds - return formatter.format(calendar.time) + fun getDate(milliseconds: Long, format: String = "dd. MM. yyyy HH:mm:ss"): String { + val calendar = Calendar.getInstance().apply { timeInMillis = milliseconds } + return SimpleDateFormat(format, Locale.getDefault()).format(calendar.time) } - - /** - * creates mainFolder, which is placed as root directory - * - * @param context - * @param intent - from ActivityResult, when user picks the directory - * @return - boolean if everything is ok - */ - fun createMainFolder(context: Context, intent: Intent?): Boolean{ - val root: Root? - StorageManagerCompat(context).also { - root = if (intent == null) { - it.getRoot(StorageManagerCompat.DEF_MAIN_ROOT) - } else { - it.deleteRoot(StorageManagerCompat.DEF_MAIN_ROOT) - it.addRoot(context, StorageManagerCompat.DEF_MAIN_ROOT, intent) - } - } - val f: DocumentFile = root?.toRootDirectory(context) ?: return false - try { - f.findFile(context.getString(R.string.app_name))?.let { - if(it.exists()){ - return true - } + fun createMainFolder(context: Context, intent: Intent?): Result { + val directoryName = context.getString(R.string.app_name) + return if (intent == null) { + NativeDocumentStorage.hasAppDirectory(context, directoryName).flatMap { exists -> + if (exists) { + Result.success(Unit) + } else { + Result.failure( + AppError(AppError.Kind.STORAGE, "Storage directory is not configured"), + ) } - - f.createDirectory(context.getString(R.string.app_name)) - return true - } catch (e: OperationFailedException) { - e.printStackTrace() - Toasty.error(context, context.getString(R.string.intro_error), Toasty.LENGTH_LONG, true).show() - } - return false - } - - /** - * checks existence of the main folder - * - * @param context - * @return true if exists - */ - fun isFolder(context: Context): Boolean { - val manager = StorageManagerCompat(context) - manager.getRoot(StorageManagerCompat.DEF_MAIN_ROOT)?.let{ - val f: DocumentFile = it.toRootDirectory(context) - return DocumentFileCompat.peekSubFolder(f, context.getString(R.string.app_name)) != null - } - return false - } - - /** - * checks if everything is ok from permission perspective and if the folder exists - * - * @param context - * @return true if exists - */ - fun isAccess(context: Context): Boolean{ - return when { - Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT -> { - isFolder(context) - } - Build.VERSION_CODES.M <= Build.VERSION.SDK_INT -> { - isFolder(context) && ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED - } - else -> { - isFolder(context) } + } else { + NativeDocumentStorage.persistRootAccess(context, intent, directoryName) } } - /** - * - * @param context - * @return whole path to main folder in string format - */ - fun getFolderName(context: Context): String { - val manager = StorageManagerCompat(context) - manager.getRoot(StorageManagerCompat.DEF_MAIN_ROOT)?.let{ - - val f: DocumentFile = it.toRootDirectory(context) - val documentFile: DocumentFile? = DocumentFileCompat.peekSubFolder(f, context.getString( - R.string.app_name - )) - - if (documentFile != null) { - val file = File(documentFile.uri.path!!) - val split = file.path.split(":".toRegex()).toTypedArray() - return when { - split.size >= 2 -> reversePath(split[1]) - split.isNotEmpty() -> reversePath(split[0]) - else -> context.getString(R.string.no_path) - } + fun isFolder(context: Context): Result = NativeDocumentStorage.hasAppDirectory( + context = context, + appDirectoryName = context.getString(R.string.app_name), + ) + + fun isAccess(context: Context): Result = isFolder(context) + + fun getFolderName(context: Context): Result = 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 { + val directory = internalMeasurementDirectory(context, folderName) + return appResult(AppError.Kind.STORAGE, "Create internal measurement directory") { + directory.exists() || directory.mkdirs() + }.flatMap { created -> + if (created) { + Result.success(Unit) + } else { + Result.failure( + AppError(AppError.Kind.STORAGE, "Create internal measurement directory"), + ) } - - return context.getString(R.string.no_path) - } - return context.getString(R.string.no_path) - } - - /** - * - * - * @param path - path to main folder - * @return reversed path, because DocumentFile is reversed - */ - private fun reversePath(path: String): String { - val parts = path.split("/".toRegex()).toTypedArray() - val stringBuilder = StringBuilder() - for (i in parts.indices.reversed()) { - stringBuilder.append(parts[i]).append("/") } - return stringBuilder.toString() - } - - /** - * creates internal measurement folder - * - * @param context - * @param folderName - measurement name - * @return - if everything is ok - */ - fun createInternalStorageMeasurementFolder(context: Context, folderName: String): Boolean{ - val mainFolder = File(context.filesDir, context.getString(R.string.app_name)) - if(!mainFolder.exists()){ - mainFolder.mkdirs() - } - - val folder = File(mainFolder, folderName) - return folder.mkdirs() } - /** - * creates measurement folder in phone - * - * @param context - * @param folderName - measurement name - * @return - if everything is ok - */ - fun createFolderMeasurement(context: Context, folderName: String): Boolean { - val manager = StorageManagerCompat(context) - val root = manager.getRoot(StorageManagerCompat.DEF_MAIN_ROOT) - if (root != null) { - val f = root.toRootDirectory(context) - if (f != null) { - val subFolder = - DocumentFileCompat.peekSubFolder(f, context.getString(R.string.app_name)) // main folder - if (subFolder != null) { - if (DocumentFileCompat.peekSubFolder(subFolder, folderName) == null) { // measurement folder - subFolder.createDirectory(folderName) - return true - } - } - } - } - return false - } + fun createFolderMeasurement(context: Context, folderName: String): Result = + NativeDocumentStorage.createMeasurementDirectory( + context = context, + appDirectoryName = context.getString(R.string.app_name), + measurementName = folderName, + ) - /** - * creates file in measurement folder - * - * @param context - * @param folderName - name of the folder - * @param mimeOfNewFile - json, txt, csv - * @param nameOfNewFile - name of the file - * @return - outputStream to store data - */ - @Throws(IOException::class) fun createFileInFolder( context: Context, folderName: String, mimeOfNewFile: String, - nameOfNewFile: String -// stringToSave: String - ): OutputStream? { - val manager = StorageManagerCompat(context) - val root = manager.getRoot(StorageManagerCompat.DEF_MAIN_ROOT) - if (root != null) { - val f = root.toRootDirectory(context) - if (f != null) { - val subFolder = DocumentFileCompat.peekSubFolder(f, context.getString(R.string.app_name)) // main folder - if (subFolder != null) { - val recordFolder = DocumentFileCompat.peekSubFolder(subFolder, folderName) // measurement folder - if (recordFolder != null) { - val documentNewFile = recordFolder.createFile(mimeOfNewFile, nameOfNewFile) // file - if (documentNewFile != null) { - return context.contentResolver.openOutputStream(documentNewFile.uri) - } - } + nameOfNewFile: String, + ): Result = NativeDocumentStorage.openMeasurementFile( + context = context, + appDirectoryName = context.getString(R.string.app_name), + measurementName = folderName, + mimeType = mimeOfNewFile, + fileName = nameOfNewFile, + ) + + fun createFileInInternalFolder(context: Context, folderName: String, nameOfFile: String): Result = + appResult(AppError.Kind.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"), + ) + } else { + appResult(AppError.Kind.STORAGE, "Open internal measurement file") { + FileOutputStream(File(directory, nameOfFile)) } } } - return null - } - - /** - * created file in internal storage - creates own folder in internal storage, where all the measurements are stored - * name should be "SensorBox" - * @param context - * @param folderName - folder to use - * @param nameOfFile - name of the file - * @return - outputStream to store data - */ - fun createFileInInternalFolder(context: Context, folderName: String, nameOfFile: String): OutputStream { - val mainFolder = File(context.filesDir, context.getString(R.string.app_name)) - if(!mainFolder.exists()){ - mainFolder.mkdirs() - } - - val folder = File(mainFolder, folderName) - if(!folder.exists()){ - folder.mkdirs() - } - val file = File(folder, nameOfFile) - return FileOutputStream(file) - } + fun deleteByNameOfFolder(context: Context, deleteFolder: String): Result = + NativeDocumentStorage.deleteMeasurement( + context = context, + appDirectoryName = context.getString(R.string.app_name), + measurementName = deleteFolder, + ) - /** - * deletes whole folder by name - * - * @param context - * @param deleteFolder - name of the folder to delete - * @return if everything is ok - */ - fun deleteByNameOfFolder( - context: Context, - deleteFolder: String - ): Boolean { - val manager = StorageManagerCompat(context) - val root = manager.getRoot(StorageManagerCompat.DEF_MAIN_ROOT) - if (root != null) { - val f = root.toRootDirectory(context) - if (f != null) { - val subFolder = - DocumentFileCompat.peekSubFolder(f, context.getString(R.string.app_name)) - if (subFolder != null) { - DocumentFileCompat.peekSubFolder(subFolder, deleteFolder)?.let { - return it.delete() // deletes everything inside too - } - } - } - } - return false + private fun internalMeasurementDirectory(context: Context, folderName: String): File { + val appDirectory = File(context.filesDir, context.getString(R.string.app_name)) + return File(appDirectory, folderName) } -} \ No newline at end of file +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt new file mode 100644 index 0000000..f110b51 --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt @@ -0,0 +1,43 @@ +package com.motionapps.sensorservices.intent + +import android.content.Context +import android.content.Intent +import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorservices.services.MeasurementService +import dagger.hilt.android.qualifiers.ApplicationContext +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.FOLDER_NAME, request.folderName) + putExtra(MeasurementService.INTERNAL_STORAGE, request.useInternalStorage) + putExtra(MeasurementService.ANDROID_SENSORS, request.sensorIds.toIntArray()) + putExtra(MeasurementService.ANDROID_SENSORS_SPEED, request.sensorSamplingPeriod) + putExtra(MeasurementService.GPS, request.includesGps) + putExtra(MeasurementService.STOP_ON_LOW_BATTERY, request.stopOnLowBattery) + putExtra(MeasurementService.USE_WAKE_LOCK, request.useWakeLock) + putExtra(MeasurementService.GPS_INTERVAL_SECONDS, request.gpsIntervalSeconds) + putExtra(MeasurementService.GPS_DISTANCE_METERS, request.gpsMinDistanceMeters) + putExtra(MeasurementService.MEASUREMENT_TYPE, request.measurementType) + putExtra(MeasurementService.START_AT_EPOCH_MILLIS, request.startAtEpochMillis) + putExtra(MeasurementService.DURATION_MILLIS, request.durationMillis) + putStringArrayListExtra(MeasurementService.NOTES, ArrayList(request.notes)) + putExtra(MeasurementService.ALARM_OFFSETS_SECONDS, request.alarmOffsetsSeconds.toIntArray()) + 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 { + val prefix = customName.trim().replace(INVALID_NAME_CHARS, "_").trim('_').take(MAX_PREFIX_LENGTH) + .ifBlank { if (measurementType == "TIMED") "timed" else "recording" } + return "${prefix}_${StorageHandler.getDate(System.currentTimeMillis(), DATE_FORMAT)}" + } + + private companion object { + const val DATE_FORMAT = "yyyy-MM-dd_HH-mm-ss" + const val MAX_PREFIX_LENGTH = 60 + val INVALID_NAME_CHARS = Regex("[^A-Za-z0-9._-]+") + } +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt new file mode 100644 index 0000000..638bdde --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt @@ -0,0 +1,22 @@ +package com.motionapps.sensorservices.intent + +data class MeasurementLaunchRequest( + val folderName: String, + val useInternalStorage: Boolean, + val sensorIds: Set, + val sensorSamplingPeriod: Int, + val includesGps: Boolean, + val stopOnLowBattery: Boolean, + val useWakeLock: Boolean, + val gpsIntervalSeconds: Int, + val gpsMinDistanceMeters: Int, + val measurementType: String = "ENDLESS", + val startAtEpochMillis: Long = System.currentTimeMillis(), + val durationMillis: Long = 0L, + val notes: List = emptyList(), + val alarmOffsetsSeconds: List = emptyList(), + 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/session/MeasurementSessionState.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt new file mode 100644 index 0000000..580a029 --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt @@ -0,0 +1,14 @@ +package com.motionapps.sensorservices.session + +sealed interface MeasurementSessionState { + data object Idle : MeasurementSessionState + + data class Running( + val folderName: String, + val startedAtElapsedRealtime: Long, + val sensorIds: List, + val includesGps: Boolean, + ) : MeasurementSessionState + + data object Stopping : MeasurementSessionState +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt new file mode 100644 index 0000000..0b2684f --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt @@ -0,0 +1,25 @@ +package com.motionapps.sensorservices.session + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MeasurementSessionStore @Inject constructor() { + private val mutableState = MutableStateFlow(MeasurementSessionState.Idle) + val state: StateFlow = mutableState.asStateFlow() + + fun markRunning(state: MeasurementSessionState.Running) { + mutableState.value = state + } + + fun markStopping() { + mutableState.value = MeasurementSessionState.Stopping + } + + fun markIdle() { + mutableState.value = MeasurementSessionState.Idle + } +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/types/EndHolder.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/types/EndHolder.kt deleted file mode 100644 index 81135e0..0000000 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/types/EndHolder.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.motionapps.sensorservices.types - -/** - * if there would be more holders, everyone must implements save method - * - */ -interface EndHolder { - suspend fun saveFile() -} \ No newline at end of file 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 788eb67..12ca4d3 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt @@ -3,151 +3,75 @@ package com.motionapps.sensorservices.types import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener -import kotlinx.coroutines.* +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.suspendAppResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import java.io.IOException import java.io.OutputStream -/** - * registers specific sensor and saves it to CSV file - * - * @property sensorId - if of the sensor to register - * @property outputStream - appropriate file outputStream - * - * @param sensorNeeds - - */ -class SensorHolder( - val sensorId: Int, - sensorNeeds: SensorNeeds, - private val outputStream: OutputStream -) : SensorEventListener, EndHolder { - - private val lineFormat: String = - "%d;%d;%s%d\n" // format of the line, %s in created by loop with size of required axes - private val axes: Int = sensorNeeds.count - private var isWriting: Boolean = false - private var buffer: StringBuffer = StringBuffer(10000) - private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) - - init { - scope.launch { - withContext(Dispatchers.IO) { - outputStream.write(sensorNeeds.head.toByteArray()) - } - } - scope.launch { - withContext(Dispatchers.IO){ - while (isActive){ - delay(10000L) - if (queue1.isNotEmpty()){ - isWriting = true - val copy = queue1.toMutableList() - queue1.clear() - copy.forEach { sensorOutput -> formatLine(sensorOutput) } - writeBuffer() - isWriting = false - } - - if (queue2.isNotEmpty() && !isWriting){ - val copy = queue2.toMutableList() - queue2.clear() - copy.forEach { sensorOutput -> formatLine(sensorOutput) } - writeBuffer() - } - } - } - +class SensorHolder(val spec: SensorSpec, outputStream: OutputStream) : SensorEventListener { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val samples = Channel(capacity = Channel.BUFFERED) + private val writer = outputStream.bufferedWriter() + private val writerJob = scope.async { + try { + 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) + samples.close() } } - private var queue1 = ArrayList() - private var queue2 = ArrayList() + @Volatile + private var writerFailure: AppError? = null override fun onSensorChanged(event: SensorEvent) { - if (isWriting){ - queue2.add(SensorOutput(event)) - return - } - queue1.add(SensorOutput(event)) - } - - private fun formatLine(sensorOutput: SensorOutput){ - var values = "" - for (i in 0 until axes) { - values += sensorOutput.values[i].toString() + ";" // formatting values - } - - buffer.append( - lineFormat.format( - sensorOutput.timestamp, - sensorOutput.timeStampUnix, - values, - sensorOutput.accuracy - ) + if (writerFailure != null) return + val result = samples.trySend( + SensorSample( + sensorTimestampNanos = event.timestamp, + unixTimestampMillis = System.currentTimeMillis(), + values = event.values.copyOf(spec.axisCount), + accuracy = event.accuracy, + ), ) + if (result.isFailure && writerFailure == null) { + writerFailure = AppError(AppError.Kind.STORAGE, "Buffer ${spec.fileName}") + } } - private fun writeBuffer() { - val bufferToWrite = buffer.toString() - buffer.setLength(0) - outputStream.write(bufferToWrite.toByteArray()) - } - - override fun onAccuracyChanged(p0: Sensor?, p1: Int) {} + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit - override suspend fun saveFile() { + suspend fun close(): Result { + 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() } scope.cancel() - if(isWriting){ - return - } - - if (queue1.isNotEmpty()){ - withContext(Dispatchers.IO){ - queue1.forEach{ output -> formatLine(output)} - writeBuffer() - } - } - - if (queue2.isNotEmpty()){ - withContext(Dispatchers.IO){ - queue2.forEach{ output -> formatLine(output)} - writeBuffer() - } - } + return results.combineAppResults(AppError.Kind.STORAGE, "Close ${spec.fileName}") } - - data class SensorOutput( - val timestamp: Long, - val timeStampUnix: Long, - val values: FloatArray, - val accuracy: Int - ) { - - constructor(sensorOutput: SensorEvent) : this( - sensorOutput.timestamp, - System.currentTimeMillis(), - sensorOutput.values, - sensorOutput.accuracy - ) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as SensorOutput - - if (timestamp != other.timestamp) return false - if (accuracy != other.accuracy) return false - if (!values.contentEquals(other.values)) return false - - return true - } - - override fun hashCode(): Int { - var result = timestamp.hashCode() - result += accuracy.hashCode() - result = 31 * result + values.contentHashCode() - return result - } +} + +class SensorSample( + val sensorTimestampNanos: Long, + val unixTimestampMillis: Long, + val values: FloatArray, + val accuracy: Int, +) { + fun toCsv(axisCount: Int): String = buildString { + append(sensorTimestampNanos).append(';') + append(unixTimestampMillis).append(';') + values.take(axisCount).forEach { value -> append(value).append(';') } + append(accuracy) } - - -} \ No newline at end of file +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorSpec.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorSpec.kt new file mode 100644 index 0000000..cbc7e80 --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorSpec.kt @@ -0,0 +1,48 @@ +package com.motionapps.sensorservices.types + +import android.hardware.Sensor + +enum class SensorSpec(val type: Int, val axisCount: Int, val fileName: String, val header: String) { + ACCELEROMETER(Sensor.TYPE_ACCELEROMETER, 3, "accelerometer.csv", "t_sensor;t_unix;x;y;z;accuracy\n"), + AMBIENT_TEMPERATURE( + Sensor.TYPE_AMBIENT_TEMPERATURE, + 1, + "ambient_temperature.csv", + "t_sensor;t_unix;value;accuracy\n", + ), + GRAVITY(Sensor.TYPE_GRAVITY, 3, "gravity.csv", "t_sensor;t_unix;x;y;z;accuracy\n"), + GYROSCOPE(Sensor.TYPE_GYROSCOPE, 3, "gyroscope.csv", "t_sensor;t_unix;x;y;z;accuracy\n"), + HEART_RATE(Sensor.TYPE_HEART_RATE, 1, "heart_rate.csv", "t_sensor;t_unix;bpm;accuracy\n"), + LIGHT(Sensor.TYPE_LIGHT, 1, "light.csv", "t_sensor;t_unix;value;accuracy\n"), + LINEAR_ACCELERATION( + Sensor.TYPE_LINEAR_ACCELERATION, + 3, + "linear_acceleration.csv", + "t_sensor;t_unix;x;y;z;accuracy\n", + ), + MAGNETIC_FIELD(Sensor.TYPE_MAGNETIC_FIELD, 3, "magnetic_field.csv", "t_sensor;t_unix;x;y;z;accuracy\n"), + PRESSURE(Sensor.TYPE_PRESSURE, 1, "pressure.csv", "t_sensor;t_unix;value;accuracy\n"), + PROXIMITY(Sensor.TYPE_PROXIMITY, 1, "proximity.csv", "t_sensor;t_unix;value;accuracy\n"), + RELATIVE_HUMIDITY( + Sensor.TYPE_RELATIVE_HUMIDITY, + 1, + "relative_humidity.csv", + "t_sensor;t_unix;value;accuracy\n", + ), + ROTATION_VECTOR(Sensor.TYPE_ROTATION_VECTOR, 4, "rotation_vector.csv", "t_sensor;t_unix;x;y;z;scalar;accuracy\n"), + STEP_COUNTER(Sensor.TYPE_STEP_COUNTER, 1, "step_counter.csv", "t_sensor;t_unix;steps;accuracy\n"), + STEP_DETECTOR(Sensor.TYPE_STEP_DETECTOR, 1, "step_detector.csv", "t_sensor;t_unix;step;accuracy\n"), + + // Kept discoverable in the sensor catalogue, but recorded by SignificantMotion's trigger listener. + SIGNIFICANT_MOTION( + Sensor.TYPE_SIGNIFICANT_MOTION, + 1, + "significant_motion.csv", + "t_sensor;t_unix;event;accuracy\n", + ), + ; + + companion object { + fun fromType(type: Int): SensorSpec? = entries.firstOrNull { it.type == type } + } +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorsNeeds.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorsNeeds.kt deleted file mode 100644 index fc72971..0000000 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorsNeeds.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.motionapps.sensorservices.types - -import android.annotation.SuppressLint -import android.content.Context -import android.hardware.Sensor -import android.hardware.SensorManager -import com.motionapps.sensorservices.R -import com.motionapps.sensorservices.types.SensorNeeds.Companion.TypeOfRepresentation.PLOT -import com.motionapps.sensorservices.types.SensorNeeds.Companion.TypeOfRepresentation.REALTIME_COUNTER -import com.motionapps.sensorservices.types.SensorNeeds.Companion.TypeOfRepresentation.TEXTVIEW - -/** - * all the sensors and their specifics for formatting and string - * - * @property id - of the sensor - * @property count - of axes - * @property oneValueTextView - PLOT, TEXTVIEW, COUNTER - * @property unit - units in which the values should be showed - * @property head - of the csv - * @property conversion - conversion rate from metric to imperial - not implemented - * @property title - for the chart - */ -enum class SensorNeeds( val id: Int, val count: Int, val oneValueTextView: Int, - val unit: String, val head: String, private val conversion: Int, val title: Int) { - - ACC(Sensor.TYPE_LINEAR_ACCELERATION, 3, PLOT, UnitsMetricSystem.acceleration, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.acc_name), - ACG(Sensor.TYPE_ACCELEROMETER, 3, PLOT, UnitsMetricSystem.acceleration, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.acg_name), - AGG(Sensor.TYPE_GRAVITY, 3, PLOT, UnitsMetricSystem.acceleration, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.agg_name), - GYRO(Sensor.TYPE_GYROSCOPE, 3, PLOT, UnitsMetricSystem.angle_acceleration, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.gyro_name), - HUM(Sensor.TYPE_RELATIVE_HUMIDITY, 1, PLOT, UnitsMetricSystem.percentage, "t_Android;t_unix;hum;a\n", ImperialConversion.DEBUG, R.string.humi_name), - MAGNET(Sensor.TYPE_MAGNETIC_FIELD, 3, PLOT, UnitsMetricSystem.induction, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.magnet_name), - PROXIMITY(Sensor.TYPE_PROXIMITY, 1, TEXTVIEW, UnitsMetricSystem.centimeter, "t_Android;t_unix;prox;a\n", ImperialConversion.DEBUG, R.string.proxi_name), - ROTATION(Sensor.TYPE_ROTATION_VECTOR, 4, PLOT, UnitsMetricSystem.nothing, "t_Android;t_unix;x;y;z;0;a\n", ImperialConversion.DEBUG, R.string.rotation_name), - PRESSURE(Sensor.TYPE_PRESSURE, 1, PLOT, UnitsMetricSystem.pressure, "t_Android;t_unix;pressure;a\n", ImperialConversion.DEBUG, R.string.pressure_name), - LIGHT(Sensor.TYPE_LIGHT, 1, PLOT, UnitsMetricSystem.lux,"t_Android;t_unix;light;a\n", ImperialConversion.DEBUG, R.string.light_name), - TEMP(Sensor.TYPE_AMBIENT_TEMPERATURE, 1, PLOT, UnitsMetricSystem.celsius, "t_Android;t_unix;temp;a\n", ImperialConversion.DEBUG, R.string.temp_name), - STEP_COUNTER(Sensor.TYPE_STEP_COUNTER, 1, TEXTVIEW, UnitsMetricSystem.steps_string, "t_Android;t_unix;steps;a\n", ImperialConversion.DEBUG, R.string.step_counter_name), - STEP_DETECTOR(Sensor.TYPE_STEP_DETECTOR, 1, REALTIME_COUNTER, UnitsMetricSystem.steps_string, "t_Android;t_unix;steps;a\n", ImperialConversion.DEBUG, R.string.step_detector_name), - @SuppressLint("InlinedApi") - HEART_RATE(Sensor.TYPE_HEART_RATE, 1, TEXTVIEW, UnitsMetricSystem.heartrate, "t_Android;t_unix;bpm;a\n", ImperialConversion.DEBUG, R.string.heart_rate), - - ACC_WEAR(Sensor.TYPE_LINEAR_ACCELERATION, 3, PLOT, UnitsMetricSystem.acceleration, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.acc_name_wear), - ACG_WEAR(Sensor.TYPE_ACCELEROMETER, 3, PLOT, UnitsMetricSystem.acceleration, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.acg_name_wear), - GYRO_WEAR(Sensor.TYPE_GYROSCOPE, 3, PLOT, UnitsMetricSystem.angle_acceleration, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.gyro_name_wear), - MAGNET_WEAR(Sensor.TYPE_MAGNETIC_FIELD, 3, PLOT, UnitsMetricSystem.induction, "t_Android;t_unix;x;y;z;a\n", ImperialConversion.DEBUG, R.string.magnet_name_wear), - - @SuppressLint("InlinedApi") - HEART_RATE_WEAR(Sensor.TYPE_HEART_RATE, 1, TEXTVIEW, UnitsMetricSystem.heartrate, "t_Android;t_unix;bpm;a\n", ImperialConversion.DEBUG, R.string.heart_rate_wear); - - - companion object{ - /** - * iterates through sensors and return list of SensorRequirement - * - * @param context - * @return - */ - fun getSensors(context: Context): ArrayList { - - val array = ArrayList() - val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager - for(sensorNeeds in values()){ - val sensor: Sensor ?= sensorManager.getDefaultSensor(sensorNeeds.id) - if(sensor != null && "WEAR" !in sensorNeeds.name){ - array.add(sensorNeeds) - } - } - return array - } - - /** - * searches sensor requirements by id - not for the Wear Os and limited for chart values - * - * @param id - of the sensor - * @return - SensorNeeds - */ - fun getSensorByIdForChart(id: Int): SensorNeeds { - for(sensorNeed: SensorNeeds in values()){ - if(sensorNeed.id == id && sensorNeed.oneValueTextView == PLOT && "WEAR" !in sensorNeed.name){ - return sensorNeed - } - } - return ACG - } - - /** - * searches sensor requirements by id - not for the Wear Os - * - * @param id - of the sensor - * @return - SensorNeeds - */ - fun getSensorById(id: Int): SensorNeeds { - for(sensorNeed: SensorNeeds in values()){ - if(sensorNeed.id == id && "WEAR" !in sensorNeed.name){ - return sensorNeed - } - } - return ACG - } - - /** - * searches sensor requirements by id - specified for Wear Os - * - * @param id - of the sensor - * @return - SensorNeeds - */ - fun getSensorByIdWearOs(id: Int): SensorNeeds { - for(sensorNeed: SensorNeeds in values()){ - if(sensorNeed.id == id && (sensorNeed.oneValueTextView == PLOT || sensorNeed.oneValueTextView == TEXTVIEW) && "WEAR" in sensorNeed.name){ - return sensorNeed - } - } - return ACG_WEAR - } - - const val GPS: String = "GPS" - - object TypeOfRepresentation{ - const val PLOT = 0 - const val TEXTVIEW = 1 - const val REALTIME_COUNTER = 3 - } - - object UnitsMetricSystem{ - const val acceleration = "m/s2" - const val angle_acceleration = "rad/s" - const val percentage = "%" - const val induction = "μT" - const val centimeter = "cm" - const val nothing = "-" - const val pressure = "hPa" - const val lux = "lx" - const val celsius = "°C" - const val steps_string = "steps" - const val heartrate = "bpm" - } - - object ImperialConversion{ - const val DEBUG = 1 - //TODO create Imperial conversion - } - } - - -} - - diff --git a/sensorservices/src/main/res/raw/alert.wav b/sensorservices/src/main/res/raw/alert.wav deleted file mode 100644 index 2e726b8ef2405e192c32866c488f231e4ced8b06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 418996 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