From e59e5aa06018a93731c7dc894d71991b888f3876 Mon Sep 17 00:00:00 2001 From: beetzung Date: Wed, 4 Mar 2026 19:35:07 +0100 Subject: [PATCH] feat(device): implement Device feature with SSOT, offline support, and settings screen - Add domain layer: Device, PendingMutation, DeviceRepository - Add Room DB: DeviceEntity, PendingMutationEntity, DAOs, DeviceLocalDataSource - Add mock DeviceRemoteDataSource and DeviceSubscriptionManager - Implement DeviceRepositoryImpl with optimistic updates and mutation queue - Add UseCases: GetMyDevices, Rename, SetLocation, SetDataSharing, TriggerFirmware - Implement DeviceSettingsScreen with rename, location, data sharing, firmware - Wire ManageScreen to GetMyDevicesUseCase and DeviceSubscriptionManager - Update navigation to pass deviceId and show DeviceSettingsScreen - Add Room 2.7.0-alpha11 and Room dependencies to SDK Made-with: Cursor --- .../features/device/DeviceSettingsScreen.kt | 303 ++++++++++++++++++ .../device/DeviceSettingsScreenContract.kt | 30 ++ .../device/DeviceSettingsViewModel.kt | 161 ++++++++++ .../device/usecases/GetDeviceUseCase.kt | 15 + .../device/usecases/GetMyDevicesUseCase.kt | 15 + .../usecases/ObservePendingSyncUseCase.kt | 15 + .../device/usecases/RenameDeviceUseCase.kt | 20 ++ .../device/usecases/SetDataSharingUseCase.kt | 14 + .../usecases/SetDeviceLocationUseCase.kt | 17 + .../usecases/TriggerFirmwareUpdateUseCase.kt | 15 + .../db3/airmq/features/manage/ManageScreen.kt | 4 +- .../airmq/features/manage/ManageViewModel.kt | 65 ++-- .../features/navigation/AirMQNavGraph.kt | 11 +- app/src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 4 + sdk/build.gradle.kts | 5 + .../sdk/device/data/DeviceRepositoryImpl.kt | 129 ++++++++ .../airmq/sdk/device/data/local/DeviceDao.kt | 63 ++++ .../sdk/device/data/local/DeviceDatabase.kt | 14 + .../sdk/device/data/local/DeviceEntity.kt | 29 ++ .../data/local/DeviceLocalDataSource.kt | 129 ++++++++ .../data/local/PendingMutationEntity.kt | 21 ++ .../data/remote/DeviceRemoteDataSource.kt | 107 +++++++ .../data/remote/DeviceSubscriptionManager.kt | 63 ++++ .../db3/airmq/sdk/device/di/DeviceModule.kt | 54 ++++ .../org/db3/airmq/sdk/device/domain/Device.kt | 62 ++++ .../airmq/sdk/device/domain/DeviceLocation.kt | 11 + .../sdk/device/domain/DeviceRepository.kt | 71 ++++ .../sdk/device/domain/PendingMutation.kt | 28 ++ 29 files changed, 1451 insertions(+), 27 deletions(-) create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreen.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreenContract.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsViewModel.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/usecases/GetDeviceUseCase.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/usecases/GetMyDevicesUseCase.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/usecases/ObservePendingSyncUseCase.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/usecases/RenameDeviceUseCase.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDataSharingUseCase.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDeviceLocationUseCase.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/device/usecases/TriggerFirmwareUpdateUseCase.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDatabase.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceEntity.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/PendingMutationEntity.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceSubscriptionManager.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/Device.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceLocation.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/PendingMutation.kt diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreen.kt new file mode 100644 index 0000000..340f46e --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreen.kt @@ -0,0 +1,303 @@ +package org.db3.airmq.features.device + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.flow.collectLatest +import org.db3.airmq.R +import org.db3.airmq.features.common.AirMQButton +import org.db3.airmq.features.common.AirMQButtonStyle +import org.db3.airmq.sdk.device.domain.OnlineStatus +import org.db3.airmq.ui.theme.LegacyBackground + +@Composable +fun DeviceSettingsScreen( + deviceId: String, + onOpenLocation: () -> Unit, + onShowOnMap: () -> Unit, + onNavigateBack: () -> Unit, + viewModel: DeviceSettingsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val toastDoneText = stringResource(R.string.toast_done) + + LaunchedEffect(viewModel) { + viewModel.actions.collectLatest { action -> + when (action) { + is DeviceSettingsScreenContract.Action.ShowError -> { + snackbarHostState.showSnackbar(action.message) + } + is DeviceSettingsScreenContract.Action.ShowSuccess -> { + snackbarHostState.showSnackbar(toastDoneText) + } + is DeviceSettingsScreenContract.Action.OpenLocation -> onOpenLocation() + is DeviceSettingsScreenContract.Action.ShowOnMap -> onShowOnMap() + } + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .background(LegacyBackground) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.content_back) + ) + } + Text( + text = stringResource(R.string.title_device), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when { + uiState.isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + uiState.device == null -> { + Text( + text = stringResource(R.string.text_nothing_to_show), + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp) + ) + } + else -> { + DeviceSettingsContent( + state = uiState, + onEvent = viewModel::onEvent, + deviceName = uiState.device?.name ?: "" + ) + } + } + } + } +} + +@Composable +private fun DeviceSettingsContent( + state: DeviceSettingsScreenContract.State, + onEvent: (DeviceSettingsScreenContract.Event) -> Unit, + deviceName: String +) { + val device = state.device!! + var showRenameDialog by remember { mutableStateOf(false) } + var renameText by remember(deviceName) { mutableStateOf(deviceName) } + + if (showRenameDialog) { + AlertDialog( + onDismissRequest = { showRenameDialog = false }, + title = { Text(stringResource(R.string.dialog_rename_title)) }, + text = { + Column { + Text( + text = stringResource(R.string.hint_new_name), + style = MaterialTheme.typography.bodySmall + ) + BasicTextField( + value = renameText, + onValueChange = { renameText = it }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + singleLine = true + ) + } + }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { + if (renameText.trim().length >= 3) { + onEvent(DeviceSettingsScreenContract.Event.RenameSubmitted(renameText.trim())) + showRenameDialog = false + } + } + ) { + Text(stringResource(R.string.button_rename)) + } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { showRenameDialog = false }) { + Text(stringResource(R.string.button_cancel)) + } + } + ) + } + val scrollState = rememberScrollState() + val statusIcon = when (device.toOnlineStatus()) { + OnlineStatus.Online -> R.drawable.device_chip_online + else -> R.drawable.device_chip_offline + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .background(LegacyBackground) + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_chip), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.size(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.name, + style = MaterialTheme.typography.headlineSmall + ) + Text( + text = device.model, + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray + ) + } + Icon( + painter = painterResource(statusIcon), + contentDescription = device.toOnlineStatus().toString(), + modifier = Modifier.size(24.dp) + ) + } + + if (state.isPendingSync) { + Text( + text = stringResource(R.string.text_pending_sync), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(top = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + AirMQButton( + text = stringResource(R.string.button_rename), + onClick = { showRenameDialog = true }, + style = AirMQButtonStyle.Outlined, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + if (device.hasLocation()) { + Text( + text = stringResource( + R.string.text_device_location_set, + device.latitude!!, + device.longitude!! + ), + style = MaterialTheme.typography.bodyMedium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AirMQButton( + text = stringResource(R.string.title_location), + onClick = { onEvent(DeviceSettingsScreenContract.Event.OpenLocationClicked) }, + style = AirMQButtonStyle.Outlined, + modifier = Modifier.weight(1f) + ) + AirMQButton( + text = stringResource(R.string.button_view_on_map), + onClick = { onEvent(DeviceSettingsScreenContract.Event.ShowOnMapClicked) }, + style = AirMQButtonStyle.Outlined, + modifier = Modifier.weight(1f) + ) + } + } else { + AirMQButton( + text = stringResource(R.string.button_register), + onClick = { onEvent(DeviceSettingsScreenContract.Event.OpenLocationClicked) }, + style = AirMQButtonStyle.Outlined, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.text_data_sharing), + style = MaterialTheme.typography.bodyLarge + ) + Switch( + checked = device.dataSharingEnabled, + onCheckedChange = { onEvent(DeviceSettingsScreenContract.Event.DataSharingToggled(it)) } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + AirMQButton( + text = stringResource(R.string.button_firmware_update), + onClick = { onEvent(DeviceSettingsScreenContract.Event.FirmwareUpdateClicked) }, + style = AirMQButtonStyle.Gradient, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreenContract.kt new file mode 100644 index 0000000..8b8c106 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreenContract.kt @@ -0,0 +1,30 @@ +package org.db3.airmq.features.device + +import org.db3.airmq.sdk.device.domain.Device + +object DeviceSettingsScreenContract { + + data class State( + val device: Device? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val isPendingSync: Boolean = false + ) + + sealed interface Action { + data class ShowError(val message: String) : Action + data object ShowSuccess : Action + data object OpenLocation : Action + data object ShowOnMap : Action + } + + sealed interface Event { + data class RenameSubmitted(val name: String) : Event + data class LocationSet(val latitude: Double, val longitude: Double) : Event + data object LocationRemoved : Event + data class DataSharingToggled(val enabled: Boolean) : Event + data object FirmwareUpdateClicked : Event + data object OpenLocationClicked : Event + data object ShowOnMapClicked : Event + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsViewModel.kt new file mode 100644 index 0000000..c09d157 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsViewModel.kt @@ -0,0 +1,161 @@ +package org.db3.airmq.features.device + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.db3.airmq.features.device.usecases.GetDeviceUseCase +import org.db3.airmq.features.device.usecases.ObservePendingSyncUseCase +import org.db3.airmq.features.device.usecases.RenameDeviceUseCase +import org.db3.airmq.features.device.usecases.SetDataSharingUseCase +import org.db3.airmq.features.device.usecases.SetDeviceLocationUseCase +import org.db3.airmq.features.device.usecases.TriggerFirmwareUpdateUseCase +import javax.inject.Inject + +@HiltViewModel +class DeviceSettingsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getDeviceUseCase: GetDeviceUseCase, + private val renameDeviceUseCase: RenameDeviceUseCase, + private val setDeviceLocationUseCase: SetDeviceLocationUseCase, + private val setDataSharingUseCase: SetDataSharingUseCase, + private val triggerFirmwareUpdateUseCase: TriggerFirmwareUpdateUseCase, + private val observePendingSyncUseCase: ObservePendingSyncUseCase +) : ViewModel() { + + private val deviceId: String = checkNotNull(savedStateHandle.get("deviceId")) + + private val _uiState = MutableStateFlow(DeviceSettingsScreenContract.State()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + val actions: SharedFlow = _actions.asSharedFlow() + + init { + viewModelScope.launch { + combine( + getDeviceUseCase(deviceId), + observePendingSyncUseCase(deviceId) + ) { device, hasPending -> + device to hasPending + }.collect { (device, hasPending) -> + _uiState.update { + it.copy( + device = device, + isPendingSync = hasPending + ) + } + } + } + } + + fun onEvent(event: DeviceSettingsScreenContract.Event) { + when (event) { + is DeviceSettingsScreenContract.Event.RenameSubmitted -> renameDevice(event.name) + is DeviceSettingsScreenContract.Event.LocationSet -> setLocation(event.latitude, event.longitude) + is DeviceSettingsScreenContract.Event.LocationRemoved -> removeLocation() + is DeviceSettingsScreenContract.Event.DataSharingToggled -> setDataSharing(event.enabled) + is DeviceSettingsScreenContract.Event.FirmwareUpdateClicked -> triggerFirmwareUpdate() + is DeviceSettingsScreenContract.Event.OpenLocationClicked -> _actions.tryEmit( + DeviceSettingsScreenContract.Action.OpenLocation + ) + is DeviceSettingsScreenContract.Event.ShowOnMapClicked -> _actions.tryEmit( + DeviceSettingsScreenContract.Action.ShowOnMap + ) + } + } + + private fun renameDevice(name: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = renameDeviceUseCase(deviceId, name) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) + } else { + _actions.tryEmit( + DeviceSettingsScreenContract.Action.ShowError( + result.exceptionOrNull()?.message ?: "Failed to rename" + ) + ) + } + } + } + + private fun setLocation(latitude: Double, longitude: Double) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = setDeviceLocationUseCase.setLocation(deviceId, latitude, longitude) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) + } else { + _actions.tryEmit( + DeviceSettingsScreenContract.Action.ShowError( + result.exceptionOrNull()?.message ?: "Failed to set location" + ) + ) + } + } + } + + private fun removeLocation() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = setDeviceLocationUseCase.removeLocation(deviceId) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) + } else { + _actions.tryEmit( + DeviceSettingsScreenContract.Action.ShowError( + result.exceptionOrNull()?.message ?: "Failed to remove location" + ) + ) + } + } + } + + private fun setDataSharing(enabled: Boolean) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = setDataSharingUseCase(deviceId, enabled) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) + } else { + _actions.tryEmit( + DeviceSettingsScreenContract.Action.ShowError( + result.exceptionOrNull()?.message ?: "Failed to update data sharing" + ) + ) + } + } + } + + private fun triggerFirmwareUpdate() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = triggerFirmwareUpdateUseCase(deviceId) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) + } else { + _actions.tryEmit( + DeviceSettingsScreenContract.Action.ShowError( + result.exceptionOrNull()?.message ?: "Failed to trigger firmware update" + ) + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/GetDeviceUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/GetDeviceUseCase.kt new file mode 100644 index 0000000..d1d0af0 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/GetDeviceUseCase.kt @@ -0,0 +1,15 @@ +package org.db3.airmq.features.device.usecases + +import kotlinx.coroutines.flow.Flow +import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to observe a single device by ID. + */ +class GetDeviceUseCase @Inject constructor( + private val repository: DeviceRepository +) { + operator fun invoke(deviceId: String): Flow = repository.observeDevice(deviceId) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/GetMyDevicesUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/GetMyDevicesUseCase.kt new file mode 100644 index 0000000..2266e05 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/GetMyDevicesUseCase.kt @@ -0,0 +1,15 @@ +package org.db3.airmq.features.device.usecases + +import kotlinx.coroutines.flow.Flow +import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to observe the current user's devices from local database. + */ +class GetMyDevicesUseCase @Inject constructor( + private val repository: DeviceRepository +) { + operator fun invoke(): Flow> = repository.observeDevices() +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/ObservePendingSyncUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/ObservePendingSyncUseCase.kt new file mode 100644 index 0000000..cdf1d45 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/ObservePendingSyncUseCase.kt @@ -0,0 +1,15 @@ +package org.db3.airmq.features.device.usecases + +import kotlinx.coroutines.flow.Flow +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to observe whether a device has pending mutations (offline changes to sync). + */ +class ObservePendingSyncUseCase @Inject constructor( + private val repository: DeviceRepository +) { + operator fun invoke(deviceId: String): Flow = + repository.observeHasPendingMutations(deviceId) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/RenameDeviceUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/RenameDeviceUseCase.kt new file mode 100644 index 0000000..a997ecf --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/RenameDeviceUseCase.kt @@ -0,0 +1,20 @@ +package org.db3.airmq.features.device.usecases + +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to rename a device. + * Validates input and delegates to repository. + */ +class RenameDeviceUseCase @Inject constructor( + private val repository: DeviceRepository +) { + suspend operator fun invoke(deviceId: String, newName: String): Result { + val trimmed = newName.trim() + if (trimmed.length < 3) { + return Result.failure(IllegalArgumentException("Name must be at least 3 characters")) + } + return repository.renameDevice(deviceId, trimmed) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDataSharingUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDataSharingUseCase.kt new file mode 100644 index 0000000..2a267a8 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDataSharingUseCase.kt @@ -0,0 +1,14 @@ +package org.db3.airmq.features.device.usecases + +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to enable or disable data sharing for a device. + */ +class SetDataSharingUseCase @Inject constructor( + private val repository: DeviceRepository +) { + suspend operator fun invoke(deviceId: String, enabled: Boolean): Result = + repository.setDataSharing(deviceId, enabled) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDeviceLocationUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDeviceLocationUseCase.kt new file mode 100644 index 0000000..d9100b1 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDeviceLocationUseCase.kt @@ -0,0 +1,17 @@ +package org.db3.airmq.features.device.usecases + +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to set or remove device location. + */ +class SetDeviceLocationUseCase @Inject constructor( + private val repository: DeviceRepository +) { + suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result = + repository.setLocation(deviceId, latitude, longitude) + + suspend fun removeLocation(deviceId: String): Result = + repository.removeLocation(deviceId) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/TriggerFirmwareUpdateUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/TriggerFirmwareUpdateUseCase.kt new file mode 100644 index 0000000..b30c959 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/TriggerFirmwareUpdateUseCase.kt @@ -0,0 +1,15 @@ +package org.db3.airmq.features.device.usecases + +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to trigger firmware update for a device. + * Requires connectivity - blocks when offline. + */ +class TriggerFirmwareUpdateUseCase @Inject constructor( + private val repository: DeviceRepository +) { + suspend operator fun invoke(deviceId: String): Result = + repository.triggerFirmwareUpdate(deviceId) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt index 97f0281..2c9d0e9 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt @@ -53,7 +53,7 @@ import org.db3.airmq.ui.theme.LegacyNavGradientStart @Composable fun ManageScreen( - onOpenDevice: () -> Unit, + onOpenDevice: (String) -> Unit, onOpenSetup: () -> Unit, onOpenSettings: () -> Unit, onOpenLogin: () -> Unit, @@ -68,7 +68,7 @@ fun ManageScreen( Action.OpenLogin -> onOpenLogin() Action.OpenSettings -> onOpenSettings() Action.OpenSetup -> onOpenSetup() - is Action.OpenDevice -> onOpenDevice() + is Action.OpenDevice -> onOpenDevice(action.deviceId) is Action.OpenLocation -> onOpenLocation() is Action.OpenAddLocation -> onOpenAddLocation() } diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt index a16aeb0..a378c28 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt @@ -6,25 +6,31 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.db3.airmq.R +import org.db3.airmq.features.device.usecases.GetMyDevicesUseCase import org.db3.airmq.features.manage.ManageScreenContract.Action import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem import org.db3.airmq.features.manage.ManageScreenContract.Event import org.db3.airmq.features.manage.ManageScreenContract.State import org.db3.airmq.sdk.auth.AuthService import org.db3.airmq.sdk.auth.model.User +import org.db3.airmq.sdk.device.data.remote.DeviceSubscriptionManager +import org.db3.airmq.sdk.device.domain.OnlineStatus @HiltViewModel class ManageViewModel @Inject constructor( @ApplicationContext private val appContext: Context, - private val authService: AuthService + private val authService: AuthService, + private val getMyDevicesUseCase: GetMyDevicesUseCase, + private val subscriptionManager: DeviceSubscriptionManager ) : ViewModel() { private val _uiState = MutableStateFlow(initialState()) @@ -35,6 +41,21 @@ class ManageViewModel @Inject constructor( init { refreshAuthState() + viewModelScope.launch { + getMyDevicesUseCase().collect { devices -> + val session = authService.getUser() + if (session?.isAuthenticated == true) { + val user = session + _uiState.update { state -> + if (state.isAuthorized) { + state.copy( + devices = devices.map { device -> device.toDeviceItem(appContext) } + ) + } else state + } + } + } + } } fun onEvent(event: Event) { @@ -53,10 +74,12 @@ class ManageViewModel @Inject constructor( private fun refreshAuthState() { viewModelScope.launch { val session = authService.getUser() - _uiState.value = if (session?.isAuthenticated == true) { - authorizedState(session) + if (session?.isAuthenticated == true) { + subscriptionManager.start() + _uiState.value = authorizedState(session) } else { - anonymousState() + subscriptionManager.stop() + _uiState.value = anonymousState() } } } @@ -73,21 +96,21 @@ class ManageViewModel @Inject constructor( userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user), userEmail = user.email ?: "", devicesLabel = "", - devices = listOf( - DeviceItem( - id = "device-1", - name = appContext.getString(R.string.mock_device_name_42), - extra = "mobile", - status = appContext.getString(R.string.map_status_online), - hasLocation = true - ), - DeviceItem( - id = "device-2", - name = appContext.getString(R.string.mock_device_name_17), - extra = "mobile", - status = appContext.getString(R.string.map_status_offline), - hasLocation = false - ) - ) + devices = emptyList() ) + + private fun org.db3.airmq.sdk.device.domain.Device.toDeviceItem(context: Context): DeviceItem { + val statusText = when (toOnlineStatus()) { + OnlineStatus.Online -> context.getString(R.string.map_status_online) + OnlineStatus.Offline -> context.getString(R.string.map_status_offline) + OnlineStatus.Unknown, OnlineStatus.Stale -> context.getString(R.string.map_status_offline) + } + return DeviceItem( + id = id, + name = name, + extra = model.ifEmpty { "mobile" }, + status = statusText, + hasLocation = hasLocation() + ) + } } diff --git a/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt b/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt index d73e1f4..71f7a23 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt @@ -34,7 +34,7 @@ import org.db3.airmq.features.constructor.SelectMapWidgetLocationScreen import org.db3.airmq.features.constructor.WidgetConstructorScreen import org.db3.airmq.features.dashboard.DashboardScreen import org.db3.airmq.features.debug.DebugScreen -import org.db3.airmq.features.device.DeviceScreen +import org.db3.airmq.features.device.DeviceSettingsScreen import org.db3.airmq.features.entry.SplashScreen import org.db3.airmq.features.entry.WizardScreen import org.db3.airmq.features.location.LocationScreen @@ -147,7 +147,9 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) { } composable(AirMqRoutes.MANAGE) { ManageScreen( - onOpenDevice = { navController.navigate(AirMqRoutes.device()) }, + onOpenDevice = { deviceId -> + navController.navigate(AirMqRoutes.device(deviceId)) + }, onOpenSetup = { navController.navigate(AirMqRoutes.SETUP) }, onOpenSettings = { navController.navigate(AirMqRoutes.SETTINGS) }, onOpenLogin = { navController.navigate(AirMqRoutes.LOGIN) }, @@ -164,10 +166,11 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) { } ) ) { backStackEntry -> - DeviceScreen( + DeviceSettingsScreen( deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id", onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) }, - onShowOnMap = { navController.navigate(AirMqRoutes.MAP) } + onShowOnMap = { navController.navigate(AirMqRoutes.MAP) }, + onNavigateBack = { navController.popBackStack() } ) } composable(AirMqRoutes.CITY) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7022e1b..bec4bc0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -162,6 +162,9 @@ Public Private Register device to change visibility + Changes will sync when online + Data sharing + Update firmware Rename Update Unregister diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d7f685..c8e3259 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ androidxCredentials = "1.5.0" googleid = "1.1.1" kotlinxCoroutinesTest = "1.9.0" mockk = "1.13.13" +room = "2.7.0-alpha11" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -59,6 +60,9 @@ androidx-credentials-play-services-auth = { group = "androidx.credentials", name googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } mockk-jvm = { group = "io.mockk", name = "mockk-jvm", version.ref = "mockk" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index c09cd20..f163b12 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -47,6 +47,11 @@ apollo { } dependencies { + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + // Apollo GraphQL implementation(libs.apollo.runtime) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt new file mode 100644 index 0000000..a2bdc20 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt @@ -0,0 +1,129 @@ +package org.db3.airmq.sdk.device.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.db3.airmq.sdk.device.data.local.DeviceLocalDataSource +import org.db3.airmq.sdk.device.data.remote.DeviceRemoteDataSource +import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.DeviceRepository +import org.db3.airmq.sdk.device.domain.OnlineFreshness +import org.db3.airmq.sdk.device.domain.PendingMutation +import org.db3.airmq.sdk.device.domain.PendingMutationType +import java.util.UUID +import javax.inject.Inject + +/** + * Implementation of DeviceRepository. + * Local DB is SSOT; subscription updates DB; mutations use optimistic update + queue. + */ +class DeviceRepositoryImpl @Inject constructor( + private val localDataSource: DeviceLocalDataSource, + private val remoteDataSource: DeviceRemoteDataSource +) : DeviceRepository { + + override fun observeDevices(): Flow> = + localDataSource.observeDevices() + + override fun observeDevice(deviceId: String): Flow = + localDataSource.observeDevice(deviceId) + + override suspend fun renameDevice(deviceId: String, newName: String): Result { + val current = localDataSource.getDevice(deviceId) ?: return Result.failure(NoSuchElementException("Device not found: $deviceId")) + val previousName = current.name + + localDataSource.updateName(deviceId, newName) + val mutationId = UUID.randomUUID().toString() + localDataSource.enqueuePendingMutation( + PendingMutation( + id = mutationId, + type = PendingMutationType.RENAME, + deviceId = deviceId, + payload = """{"name":"$newName"}""", + createdAt = System.currentTimeMillis() + ) + ) + + val result = remoteDataSource.renameDevice(deviceId, newName) + if (result.isSuccess) { + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.RENAME) + } else { + localDataSource.updateName(deviceId, previousName) + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.RENAME) + return result + } + return Result.success(Unit) + } + + override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result { + localDataSource.updateLocation(deviceId, latitude, longitude, null) + val mutationId = UUID.randomUUID().toString() + localDataSource.enqueuePendingMutation( + PendingMutation( + id = mutationId, + type = PendingMutationType.LOCATION, + deviceId = deviceId, + payload = """{"latitude":$latitude,"longitude":$longitude}""", + createdAt = System.currentTimeMillis() + ) + ) + + val result = remoteDataSource.setLocation(deviceId, latitude, longitude) + if (result.isSuccess) { + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.LOCATION) + } else { + localDataSource.updateLocation(deviceId, null, null, null) + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.LOCATION) + return result + } + return Result.success(Unit) + } + + override suspend fun removeLocation(deviceId: String): Result { + localDataSource.updateLocation(deviceId, null, null, null) + val result = remoteDataSource.removeLocation(deviceId) + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.LOCATION) + return result + } + + override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result { + val current = localDataSource.getDevice(deviceId) ?: return Result.failure(NoSuchElementException("Device not found: $deviceId")) + val previous = current.dataSharingEnabled + + localDataSource.updateDataSharing(deviceId, enabled) + val mutationId = UUID.randomUUID().toString() + localDataSource.enqueuePendingMutation( + PendingMutation( + id = mutationId, + type = PendingMutationType.DATA_SHARING, + deviceId = deviceId, + payload = """{"enabled":$enabled}""", + createdAt = System.currentTimeMillis() + ) + ) + + val result = remoteDataSource.setDataSharing(deviceId, enabled) + if (result.isSuccess) { + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.DATA_SHARING) + } else { + localDataSource.updateDataSharing(deviceId, previous) + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.DATA_SHARING) + return result + } + return Result.success(Unit) + } + + override suspend fun triggerFirmwareUpdate(deviceId: String): Result = + remoteDataSource.triggerFirmwareUpdate(deviceId) + + override fun observeHasPendingMutations(deviceId: String): Flow = + localDataSource.observeHasPendingMutations(deviceId) + + override suspend fun markOnlineStatusStale() { + localDataSource.markAllOnlineStatusStale() + } + + override suspend fun refreshFromSubscription(devices: List) { + val withFreshness = devices.map { it.copy(onlineFreshness = OnlineFreshness.Fresh) } + localDataSource.upsertDevices(withFreshness) + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt new file mode 100644 index 0000000..f2010a1 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt @@ -0,0 +1,63 @@ +package org.db3.airmq.sdk.device.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface DeviceDao { + + @Query("SELECT * FROM device ORDER BY name ASC") + fun observeDevices(): Flow> + + @Query("SELECT * FROM device WHERE id = :deviceId") + fun observeDevice(deviceId: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertDevices(devices: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertDevice(device: DeviceEntity) + + @Query("UPDATE device SET name = :newName WHERE id = :deviceId") + suspend fun updateName(deviceId: String, newName: String) + + @Query("UPDATE device SET latitude = :latitude, longitude = :longitude, city = :city WHERE id = :deviceId") + suspend fun updateLocation(deviceId: String, latitude: Double?, longitude: Double?, city: String?) + + @Query("UPDATE device SET dataSharingEnabled = :enabled WHERE id = :deviceId") + suspend fun updateDataSharing(deviceId: String, enabled: Boolean) + + @Query("UPDATE device SET isOnline = :isOnline, isOnlineUpdatedAt = :updatedAt WHERE id = :deviceId") + suspend fun updateOnlineStatus(deviceId: String, isOnline: Boolean, updatedAt: Long) + + @Query("UPDATE device SET isOnlineUpdatedAt = NULL") + suspend fun markAllOnlineStatusStale() + + @Query("SELECT * FROM device WHERE id = :deviceId") + suspend fun getDevice(deviceId: String): DeviceEntity? +} + +@Dao +interface PendingMutationDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(mutation: PendingMutationEntity) + + @Query("DELETE FROM pending_mutation WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("SELECT * FROM pending_mutation WHERE deviceId = :deviceId ORDER BY createdAt ASC") + fun observePendingMutationsForDevice(deviceId: String): Flow> + + @Query("SELECT COUNT(*) FROM pending_mutation WHERE deviceId = :deviceId") + fun observePendingMutationCount(deviceId: String): Flow + + @Query("SELECT * FROM pending_mutation ORDER BY createdAt ASC") + suspend fun getAllPending(): List + + @Query("DELETE FROM pending_mutation WHERE deviceId = :deviceId AND type = :type") + suspend fun deleteByDeviceAndType(deviceId: String, type: String) +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDatabase.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDatabase.kt new file mode 100644 index 0000000..a113e90 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDatabase.kt @@ -0,0 +1,14 @@ +package org.db3.airmq.sdk.device.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [DeviceEntity::class, PendingMutationEntity::class], + version = 1, + exportSchema = false +) +abstract class DeviceDatabase : RoomDatabase() { + abstract fun deviceDao(): DeviceDao + abstract fun pendingMutationDao(): PendingMutationDao +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceEntity.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceEntity.kt new file mode 100644 index 0000000..552006d --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceEntity.kt @@ -0,0 +1,29 @@ +package org.db3.airmq.sdk.device.data.local + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room entity for device storage. + * isOnline is stored with isOnlineUpdatedAt for freshness tracking. + */ +@Entity( + tableName = "device", + indices = [Index(value = ["ownerId"])] +) +data class DeviceEntity( + @PrimaryKey + val id: String, + val name: String, + val model: String, + val firmwareVersion: String, + val locationId: String? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val dataSharingEnabled: Boolean = false, + val isOnline: Boolean = false, + val isOnlineUpdatedAt: Long? = null, + val ownerId: String? = null, + val city: String? = null +) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt new file mode 100644 index 0000000..571a293 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt @@ -0,0 +1,129 @@ +package org.db3.airmq.sdk.device.data.local + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.OnlineFreshness +import org.db3.airmq.sdk.device.domain.PendingMutation +import org.db3.airmq.sdk.device.domain.PendingMutationType +import javax.inject.Inject + +/** + * Local data source for devices and pending mutations. + * Maps between Room entities and domain models. + */ +class DeviceLocalDataSource @Inject constructor( + private val deviceDao: DeviceDao, + private val pendingMutationDao: PendingMutationDao +) { + private val onlineStatusTtlMs = 5 * 60 * 1000L // 5 minutes + + fun observeDevices(): Flow> = + deviceDao.observeDevices().map { entities -> + entities.map { it.toDomain() } + } + + fun observeDevice(deviceId: String): Flow = + deviceDao.observeDevice(deviceId).map { it?.toDomain() } + + suspend fun upsertDevices(devices: List) { + val entities = devices.map { it.toEntity() } + deviceDao.upsertDevices(entities) + } + + suspend fun upsertDevice(device: Device) { + deviceDao.upsertDevice(device.toEntity()) + } + + suspend fun updateName(deviceId: String, newName: String) { + deviceDao.updateName(deviceId, newName) + } + + suspend fun updateLocation(deviceId: String, latitude: Double?, longitude: Double?, city: String?) { + deviceDao.updateLocation(deviceId, latitude, longitude, city) + } + + suspend fun updateDataSharing(deviceId: String, enabled: Boolean) { + deviceDao.updateDataSharing(deviceId, enabled) + } + + suspend fun updateOnlineStatus(deviceId: String, isOnline: Boolean, updatedAt: Long) { + deviceDao.updateOnlineStatus(deviceId, isOnline, updatedAt) + } + + suspend fun markAllOnlineStatusStale() { + deviceDao.markAllOnlineStatusStale() + } + + suspend fun getDevice(deviceId: String): Device? = deviceDao.getDevice(deviceId)?.toDomain() + + suspend fun enqueuePendingMutation(mutation: PendingMutation) { + val entity = PendingMutationEntity( + id = mutation.id, + type = mutation.type.name, + deviceId = mutation.deviceId, + payload = mutation.payload, + createdAt = mutation.createdAt + ) + pendingMutationDao.insert(entity) + } + + fun observeHasPendingMutations(deviceId: String): Flow = + pendingMutationDao.observePendingMutationCount(deviceId).map { it > 0 } + + suspend fun getPendingMutations(): List = + pendingMutationDao.getAllPending().map { it.toDomain() } + + suspend fun removePendingMutation(id: String) { + pendingMutationDao.deleteById(id) + } + + suspend fun removePendingMutationsForDevice(deviceId: String, type: PendingMutationType) { + pendingMutationDao.deleteByDeviceAndType(deviceId, type.name) + } + + private fun DeviceEntity.toDomain(): Device { + val freshness = when { + isOnlineUpdatedAt == null -> OnlineFreshness.Unknown + System.currentTimeMillis() - isOnlineUpdatedAt > onlineStatusTtlMs -> OnlineFreshness.Stale + else -> OnlineFreshness.Fresh + } + return Device( + id = id, + name = name, + model = model, + firmwareVersion = firmwareVersion, + locationId = locationId, + latitude = latitude, + longitude = longitude, + city = city, + dataSharingEnabled = dataSharingEnabled, + isOnline = isOnline, + onlineFreshness = freshness, + ownerId = ownerId + ) + } + + private fun Device.toEntity(): DeviceEntity = DeviceEntity( + id = id, + name = name, + model = model, + firmwareVersion = firmwareVersion, + locationId = locationId, + latitude = latitude, + longitude = longitude, + dataSharingEnabled = dataSharingEnabled, + isOnline = isOnline, + isOnlineUpdatedAt = if (onlineFreshness == OnlineFreshness.Fresh) System.currentTimeMillis() else null, + ownerId = ownerId, + city = city + ) + + private fun PendingMutationEntity.toDomain(): PendingMutation = PendingMutation( + id = id, + type = PendingMutationType.valueOf(type), + deviceId = deviceId, + payload = payload, + createdAt = createdAt + ) +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/PendingMutationEntity.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/PendingMutationEntity.kt new file mode 100644 index 0000000..26431aa --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/PendingMutationEntity.kt @@ -0,0 +1,21 @@ +package org.db3.airmq.sdk.device.data.local + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room entity for pending mutations in the offline queue. + */ +@Entity( + tableName = "pending_mutation", + indices = [Index(value = ["deviceId"]), Index(value = ["createdAt"])] +) +data class PendingMutationEntity( + @PrimaryKey + val id: String, + val type: String, + val deviceId: String, + val payload: String, + val createdAt: Long +) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt new file mode 100644 index 0000000..25aafc3 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt @@ -0,0 +1,107 @@ +package org.db3.airmq.sdk.device.data.remote + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.OnlineFreshness +import javax.inject.Inject + +/** + * Remote data source for devices. + * Phase 1: Returns mock data. Phase 2: Apollo subscription + mutations. + */ +interface DeviceRemoteDataSource { + + /** + * Observe device list from GraphQL subscription. + * Phase 1: Mock flow. Phase 2: Apollo subscription. + */ + fun observeDevicesSubscription(): Flow> + + /** + * Execute rename mutation. Phase 1: No-op. Phase 2: Apollo mutation. + */ + suspend fun renameDevice(deviceId: String, newName: String): Result + + /** + * Execute set location mutation. Phase 1: No-op. Phase 2: Apollo mutation. + */ + suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result + + /** + * Execute remove location mutation. Phase 1: No-op. Phase 2: Apollo mutation. + */ + suspend fun removeLocation(deviceId: String): Result + + /** + * Execute data sharing mutation. Phase 1: No-op. Phase 2: Apollo mutation. + */ + suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result + + /** + * Execute firmware update. Phase 1: No-op. Phase 2: Apollo mutation. + */ + suspend fun triggerFirmwareUpdate(deviceId: String): Result +} + +/** + * Mock implementation for Phase 1. + */ +class MockDeviceRemoteDataSource @Inject constructor() : DeviceRemoteDataSource { + + override fun observeDevicesSubscription(): Flow> = flow { + // Emit mock device list + val mockDevices = listOf( + Device( + id = "device-1", + name = "AirMQ #42", + model = "mobile", + firmwareVersion = "1.0", + locationId = "loc-1", + latitude = 53.9, + longitude = 27.5, + city = "Minsk", + dataSharingEnabled = true, + isOnline = true, + onlineFreshness = OnlineFreshness.Fresh, + ownerId = "user-1" + ), + Device( + id = "device-2", + name = "AirMQ #17", + model = "mobile", + firmwareVersion = "1.0", + locationId = null, + latitude = null, + longitude = null, + city = null, + dataSharingEnabled = false, + isOnline = false, + onlineFreshness = OnlineFreshness.Fresh, + ownerId = "user-1" + ) + ) + emit(mockDevices) + // Keep flow active and re-emit periodically to simulate subscription updates + while (true) { + delay(30_000) + emit(mockDevices) + } + } + + override suspend fun renameDevice(deviceId: String, newName: String): Result = + Result.success(Unit) + + override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result = + Result.success(Unit) + + override suspend fun removeLocation(deviceId: String): Result = + Result.success(Unit) + + override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result = + Result.success(Unit) + + override suspend fun triggerFirmwareUpdate(deviceId: String): Result = + Result.success(Unit) +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceSubscriptionManager.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceSubscriptionManager.kt new file mode 100644 index 0000000..3aab14b --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceSubscriptionManager.kt @@ -0,0 +1,63 @@ +package org.db3.airmq.sdk.device.data.remote + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages the GraphQL subscription lifecycle. + * Subscribes when user is authenticated and network available. + * On each payload, upserts devices into local DB via repository. + */ +@Singleton +class DeviceSubscriptionManager @Inject constructor( + private val remoteDataSource: DeviceRemoteDataSource, + private val repository: DeviceRepository +) { + private var scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var isSubscribed = false + + /** + * Start the subscription. Call when user is authenticated. + */ + fun start() { + if (isSubscribed) return + isSubscribed = true + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + remoteDataSource.observeDevicesSubscription() + .catch { + scope.launch { + repository.markOnlineStatusStale() + } + } + .onEach { devices -> + repository.refreshFromSubscription(devices) + } + .launchIn(scope) + } + + /** + * Stop the subscription. Call on logout or when going offline. + */ + fun stop() { + isSubscribed = false + scope.cancel() + } + + /** + * Called when subscription disconnects. Marks isOnline as stale. + */ + fun onDisconnected() { + scope.launch { + repository.markOnlineStatusStale() + } + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt new file mode 100644 index 0000000..b48c6a9 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt @@ -0,0 +1,54 @@ +package org.db3.airmq.sdk.device.di + +import android.content.Context +import androidx.room.Room +import dagger.Binds +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 +import org.db3.airmq.sdk.device.data.DeviceRepositoryImpl +import org.db3.airmq.sdk.device.data.local.DeviceDao +import org.db3.airmq.sdk.device.data.local.DeviceDatabase +import org.db3.airmq.sdk.device.data.local.PendingMutationDao +import org.db3.airmq.sdk.device.data.remote.DeviceRemoteDataSource +import org.db3.airmq.sdk.device.data.remote.MockDeviceRemoteDataSource +import org.db3.airmq.sdk.device.domain.DeviceRepository + +@Module +@InstallIn(SingletonComponent::class) +object DeviceDatabaseModule { + + @Provides + @Singleton + fun provideDeviceDatabase(@ApplicationContext context: Context): DeviceDatabase = + Room.databaseBuilder( + context, + DeviceDatabase::class.java, + "airmq_device_db" + ).build() + + @Provides + @Singleton + fun provideDeviceDao(database: DeviceDatabase): DeviceDao = database.deviceDao() + + @Provides + @Singleton + fun providePendingMutationDao(database: DeviceDatabase): PendingMutationDao = + database.pendingMutationDao() +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class DeviceBindModule { + + @Binds + @Singleton + abstract fun bindDeviceRepository(impl: DeviceRepositoryImpl): DeviceRepository + + @Binds + @Singleton + abstract fun bindDeviceRemoteDataSource(impl: MockDeviceRemoteDataSource): DeviceRemoteDataSource +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/Device.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/Device.kt new file mode 100644 index 0000000..c32cd75 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/Device.kt @@ -0,0 +1,62 @@ +package org.db3.airmq.sdk.device.domain + +/** + * Online status representation for UI display. + * Distinguishes between known, unknown, and stale states. + */ +sealed class OnlineStatus { + data object Online : OnlineStatus() + data object Offline : OnlineStatus() + data object Unknown : OnlineStatus() + data object Stale : OnlineStatus() +} + +/** + * Freshness of the isOnline value. + * Used to determine when to mark ephemeral state as stale. + */ +enum class OnlineFreshness { + Fresh, + Stale, + Unknown +} + +/** + * Domain model for an AirMQ device. + * + * @param id Unique device identifier + * @param name Display name + * @param model Device model identifier + * @param firmwareVersion Firmware version string + * @param locationId Optional location identifier + * @param latitude Optional latitude + * @param longitude Optional longitude + * @param city Optional city name for the location + * @param dataSharingEnabled Whether data sharing is enabled + * @param isOnline Whether the device is currently online + * @param onlineFreshness Freshness of the isOnline value (Fresh/Stale/Unknown) + * @param ownerId Optional owner user ID + */ +data class Device( + val id: String, + val name: String, + val model: String, + val firmwareVersion: String, + val locationId: String? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val city: String? = null, + val dataSharingEnabled: Boolean = false, + val isOnline: Boolean = false, + val onlineFreshness: OnlineFreshness = OnlineFreshness.Unknown, + val ownerId: String? = null +) { + fun hasLocation(): Boolean = + latitude != null && longitude != null && latitude != 0.0 && longitude != 0.0 + + fun toOnlineStatus(): OnlineStatus = when (onlineFreshness) { + OnlineFreshness.Fresh -> if (isOnline) OnlineStatus.Online else OnlineStatus.Offline + OnlineFreshness.Stale -> OnlineStatus.Stale + OnlineFreshness.Unknown -> OnlineStatus.Unknown + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceLocation.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceLocation.kt new file mode 100644 index 0000000..7691279 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceLocation.kt @@ -0,0 +1,11 @@ +package org.db3.airmq.sdk.device.domain + +/** + * Domain model for a device's geographic location. + */ +data class DeviceLocation( + val deviceId: String, + val latitude: Double, + val longitude: Double, + val city: String? = null +) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt new file mode 100644 index 0000000..db883f4 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt @@ -0,0 +1,71 @@ +package org.db3.airmq.sdk.device.domain + +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface for device data. + * Local database is the single source of truth; UI reads only from this. + */ +interface DeviceRepository { + + /** + * Observe the current user's devices from local database. + * Never reads directly from network. + */ + fun observeDevices(): Flow> + + /** + * Observe a single device by ID. + */ + fun observeDevice(deviceId: String): Flow + + /** + * Rename a device. Optimistic update + enqueue for sync when online. + * + * @return Result success or failure + */ + suspend fun renameDevice(deviceId: String, newName: String): Result + + /** + * Set or update device location. + * + * @return Result success or failure + */ + suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result + + /** + * Remove device location. + * + * @return Result success or failure + */ + suspend fun removeLocation(deviceId: String): Result + + /** + * Enable or disable data sharing. + * + * @return Result success or failure + */ + suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result + + /** + * Trigger firmware update. Requires connectivity. + * + * @return Result success or failure + */ + suspend fun triggerFirmwareUpdate(deviceId: String): Result + + /** + * Check if there are pending mutations for a device. + */ + fun observeHasPendingMutations(deviceId: String): Flow + + /** + * Mark isOnline as stale for all devices (e.g. on subscription disconnect). + */ + suspend fun markOnlineStatusStale(): Unit + + /** + * Refresh devices from subscription payload. Internal use by DeviceSubscriptionManager. + */ + suspend fun refreshFromSubscription(devices: List): Unit +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/PendingMutation.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/PendingMutation.kt new file mode 100644 index 0000000..708c4d5 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/PendingMutation.kt @@ -0,0 +1,28 @@ +package org.db3.airmq.sdk.device.domain + +/** + * Type of pending device mutation for offline queue. + */ +enum class PendingMutationType { + RENAME, + LOCATION, + DATA_SHARING +} + +/** + * Domain model for a pending mutation in the offline queue. + * Stored locally and synced when connectivity is restored. + * + * @param id Unique ID for the pending mutation + * @param type Type of mutation + * @param deviceId Target device ID + * @param payload JSON payload for the mutation + * @param createdAt Timestamp when enqueued + */ +data class PendingMutation( + val id: String, + val type: PendingMutationType, + val deviceId: String, + val payload: String, + val createdAt: Long +)