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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
testImplementation(testFixtures(project(":core")))
testImplementation(testFixtures(project(":wearoslib")))
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(testFixtures(project(":wearoslib")))
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:host="*"
android:pathPrefix="/sensorbox/v1/phone"
android:pathPrefix="/sensorbox/v2/phone"
android:scheme="wear" />
</intent-filter>
<intent-filter>
Expand Down
29 changes: 4 additions & 25 deletions app/src/main/java/com/motionapps/sensorbox/MsgListener.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
package com.motionapps.sensorbox

import android.content.Intent
import com.google.android.gms.wearable.ChannelClient
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import com.motionapps.sensorbox.activities.MainActivity
import com.motionapps.sensorbox.core.error.AppError
import com.motionapps.sensorbox.core.error.appResult
import com.motionapps.sensorbox.domain.paired.PhoneWearMessageDispatcher
import com.motionapps.sensorbox.domain.sync.ReceiveWearFileUseCase
import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH
import com.motionapps.wearoslib.protocol.WearCommand
import com.motionapps.wearoslib.protocol.WearCommandCodec
import com.motionapps.wearoslib.protocol.WearSensorCatalogStore
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -23,29 +16,15 @@ import javax.inject.Inject
@AndroidEntryPoint
class MsgListener : WearableListenerService() {
@Inject
lateinit var receiveWearFile: ReceiveWearFileUseCase
lateinit var messageDispatcher: PhoneWearMessageDispatcher

@Inject
lateinit var wearSensorCatalog: WearSensorCatalogStore
lateinit var receiveWearFile: ReceiveWearFileUseCase

private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != PHONE_MESSAGE_PATH) return
when (val command = WearCommandCodec.decode(messageEvent.data).getOrNull()) {
WearCommand.LaunchPhone -> {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
appResult(AppError.Kind.EXTERNAL_ACTION, "Launch phone app from Wear") {
startActivity(launchIntent)
}
}

is WearCommand.SensorList -> wearSensorCatalog.update(command.sensors)

else -> Unit
}
serviceScope.launch { messageDispatcher.dispatch(messageEvent.path, messageEvent.data) }
}

override fun onChannelOpened(channel: ChannelClient.Channel) {
Expand Down
13 changes: 11 additions & 2 deletions app/src/main/java/com/motionapps/sensorbox/SensorBoxApp.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
package com.motionapps.sensorbox

import android.app.Application
import com.motionapps.sensorbox.core.error.AppDiagnostics
import com.motionapps.sensorbox.core.error.FileDiagnostics
import com.motionapps.sensorbox.domain.paired.PhoneRecordingSessionObserver
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

/**
* building block for the Hilt dependency injection framework
*
*/
@HiltAndroidApp
class SensorBoxApp : Application() {
@Inject
lateinit var diagnostics: FileDiagnostics

@Inject
lateinit var recordingSessionObserver: PhoneRecordingSessionObserver

override fun onCreate() {
super.onCreate()
AppDiagnostics.install(this)
diagnostics.installUncaughtExceptionHandler()
recordingSessionObserver.start()
}
}
21 changes: 21 additions & 0 deletions app/src/main/java/com/motionapps/sensorbox/di/CoroutineModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.motionapps.sensorbox.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Module
@InstallIn(SingletonComponent::class)
object CoroutineModule {
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
47 changes: 47 additions & 0 deletions app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.motionapps.sensorbox.di

import android.app.Application
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build
import com.motionapps.sensorbox.core.error.DiagnosticLogger
import com.motionapps.sensorbox.core.error.DiagnosticMetadata
import com.motionapps.sensorbox.core.error.DiagnosticsStore
import com.motionapps.sensorbox.core.error.FileDiagnostics
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DiagnosticsModule {
@Provides
@Singleton
fun provideFileDiagnostics(@ApplicationContext context: Context): FileDiagnostics = FileDiagnostics(
context = context,
metadata = DiagnosticMetadata(
appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty(),
buildType = if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
"debug"
} else {
"release"
},
deviceModel = Build.MODEL,
androidVersion = Build.VERSION.RELEASE,
processName = if (Build.VERSION.SDK_INT >= 28) {
Application.getProcessName()
} else {
context.applicationInfo.processName
},
),
)

@Provides
fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = diagnostics

@Provides
fun provideDiagnosticsStore(diagnostics: FileDiagnostics): DiagnosticsStore = diagnostics
}
34 changes: 34 additions & 0 deletions app/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.motionapps.sensorbox.di

import com.motionapps.sensorbox.domain.measurement.AndroidPhoneRecordingController
import com.motionapps.sensorbox.domain.measurement.AndroidRecordingWorkflowGateway
import com.motionapps.sensorbox.domain.measurement.DocumentStorageGateway
import com.motionapps.sensorbox.domain.measurement.DocumentStorageUseCase
import com.motionapps.sensorbox.domain.measurement.PhoneRecordingController
import com.motionapps.sensorbox.domain.measurement.RecordingWorkflowGateway
import com.motionapps.sensorbox.domain.paired.PhoneWearCommandHandler
import com.motionapps.sensorbox.domain.paired.PhoneWearCommandPolicy
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
abstract class RecordingModule {
@Binds
abstract fun bindPhoneRecordingController(
implementation: AndroidPhoneRecordingController,
): PhoneRecordingController

@Binds
abstract fun bindRecordingWorkflowGateway(
implementation: AndroidRecordingWorkflowGateway,
): RecordingWorkflowGateway

@Binds
abstract fun bindDocumentStorageGateway(implementation: DocumentStorageUseCase): DocumentStorageGateway

@Binds
abstract fun bindPhoneWearCommandPolicy(implementation: PhoneWearCommandHandler): PhoneWearCommandPolicy
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,26 @@ package com.motionapps.sensorbox.domain.measurement

import android.content.Context
import android.content.Intent
import com.motionapps.sensorbox.core.error.AppResult
import com.motionapps.sensorbox.core.storage.NativeDocumentStorage
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

class DocumentStorageUseCase @Inject constructor(@ApplicationContext private val context: Context) {
fun hasStorage(): Result<Boolean> = NativeDocumentStorage.hasAppDirectory(context, APP_DIRECTORY)
interface DocumentStorageGateway {
fun hasStorage(): AppResult<Boolean>

fun displayPath(): Result<String?> = NativeDocumentStorage.displayPath(context, APP_DIRECTORY)
fun displayPath(): AppResult<String?>

fun persist(resultIntent: Intent): Result<Unit> =
fun persist(resultIntent: Intent): AppResult<Unit>
}

class DocumentStorageUseCase @Inject constructor(@ApplicationContext private val context: Context) :
DocumentStorageGateway {
override fun hasStorage(): AppResult<Boolean> = NativeDocumentStorage.hasAppDirectory(context, APP_DIRECTORY)

override fun displayPath(): AppResult<String?> = NativeDocumentStorage.displayPath(context, APP_DIRECTORY)

override fun persist(resultIntent: Intent): AppResult<Unit> =
NativeDocumentStorage.persistRootAccess(context, resultIntent, APP_DIRECTORY)

private companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,114 +1,18 @@
package com.motionapps.sensorbox.domain.measurement

import android.content.Context
import android.content.Intent
import android.hardware.SensorManager
import androidx.core.content.ContextCompat
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.error.suspendFlatMap
import com.motionapps.sensorbox.core.error.withAppError
import com.motionapps.sensorbox.core.storage.NativeDocumentStorage
import com.motionapps.sensorservices.intent.MeasurementIntentFactory
import com.motionapps.sensorservices.intent.MeasurementLaunchRequest
import com.motionapps.sensorservices.services.MeasurementService
import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY
import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH
import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase
import com.motionapps.wearoslib.protocol.WearCommand
import com.motionapps.wearoslib.protocol.WearCommandCodec
import dagger.hilt.android.qualifiers.ApplicationContext
import com.motionapps.sensorbox.core.error.AppResult
import com.motionapps.sensorbox.domain.paired.PairedRecordingCoordinator
import com.motionapps.wearoslib.protocol.WearStopReason
import javax.inject.Inject

class MeasurementControlUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val intentFactory: MeasurementIntentFactory,
private val sendWearMessage: SendWearMessageUseCase,
private val pairedRecordingCoordinator: PairedRecordingCoordinator,
private val localController: PhoneRecordingController,
) {
suspend fun start(request: MeasurementRequest): Result<Unit> =
NativeDocumentStorage.hasAppDirectory(context, APP_DIRECTORY).suspendFlatMap { storageReady ->
if (!storageReady) {
return@suspendFlatMap Result.failure(
AppError(AppError.Kind.STORAGE, "Storage directory is not configured"),
)
}
val launchRequest = request.toLaunchRequest()
val remoteStart = if (request.wearSensorIds.isNotEmpty() || request.wearIncludesGps) {
WearCommandCodec.encode(
WearCommand.StartMeasurement(
folderName = launchRequest.folderName,
sensorIds = request.wearSensorIds.toList(),
includesGps = request.wearIncludesGps,
startAtEpochMillis = launchRequest.startAtEpochMillis,
durationMillis = launchRequest.durationMillis,
measurementType = launchRequest.measurementType,
),
).suspendFlatMap { payload ->
sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload)
}
} else {
Result.success(Unit)
}
remoteStart.flatMap {
appResult(AppError.Kind.MEASUREMENT, "Launch measurement service") {
ContextCompat.startForegroundService(context, intentFactory.create(launchRequest))
Unit
}
}
}.withAppError(AppError.Kind.MEASUREMENT, "Request measurement start")
suspend fun start(request: MeasurementRequest): AppResult<Unit> = pairedRecordingCoordinator.start(request)

fun stop(): Result<Unit> = appResult(AppError.Kind.MEASUREMENT, "Request measurement stop") {
val stopIntent = Intent(context, MeasurementService::class.java)
.setAction(MeasurementService.ACTION_STOP)
context.startService(stopIntent)
}
suspend fun stop(): AppResult<Unit> = pairedRecordingCoordinator.stop(WearStopReason.USER_REQUEST)

fun annotate(text: String, timestampMillis: Long = System.currentTimeMillis()): Result<Unit> = appResult(
AppError.Kind.MEASUREMENT,
"Request measurement annotation",
) {
val intent = Intent(context, MeasurementService::class.java)
.setAction(MeasurementService.ACTION_ANNOTATE)
.putExtra(MeasurementService.ANNOTATION_TIME, timestampMillis)
.putExtra(MeasurementService.ANNOTATION_TEXT, text)
context.startService(intent)
}

private fun MeasurementRequest.toLaunchRequest(): MeasurementLaunchRequest = MeasurementLaunchRequest(
folderName = intentFactory.newFolderName(customName, measurementType),
useInternalStorage = false,
sensorIds = sensorIds,
sensorSamplingPeriod = samplingPeriod(samplingPeriodIndex),
includesGps = includesGps,
stopOnLowBattery = stopOnLowBattery,
useWakeLock = useWakeLock,
gpsIntervalSeconds = gpsIntervalSeconds,
gpsMinDistanceMeters = gpsMinDistanceMeters,
measurementType = measurementType,
startAtEpochMillis = System.currentTimeMillis() + delaySeconds.coerceAtLeast(0) * 1_000L +
if (wearSensorIds.isNotEmpty() || wearIncludesGps) WEAR_START_LEAD_MILLIS else 0L,
durationMillis = durationSeconds.coerceAtLeast(0) * 1_000L,
notes = notes,
alarmOffsetsSeconds = alarmOffsetsSeconds,
activityRecognition = activityRecognition,
activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds,
significantMotion = significantMotion,
controlsWearMeasurement = wearSensorIds.isNotEmpty() || wearIncludesGps,
)

private fun samplingPeriod(index: Int): Int = SENSOR_PERIODS.getOrElse(index) {
SensorManager.SENSOR_DELAY_FASTEST
}

private companion object {
const val APP_DIRECTORY = "SensorBox"
val SENSOR_PERIODS = intArrayOf(
SensorManager.SENSOR_DELAY_FASTEST,
SensorManager.SENSOR_DELAY_GAME,
SensorManager.SENSOR_DELAY_UI,
SensorManager.SENSOR_DELAY_NORMAL,
)
const val WEAR_START_LEAD_MILLIS = 1_000L
}
fun annotate(text: String, timestampMillis: Long = System.currentTimeMillis()): AppResult<Unit> =
localController.annotate(text, timestampMillis)
}
Loading