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
This commit is contained in:
2026-03-04 19:35:07 +01:00
parent ca5cf8c439
commit e59e5aa060
29 changed files with 1451 additions and 27 deletions

View File

@@ -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()
)
}
}

View File

@@ -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
}
}

View File

@@ -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<String>("deviceId"))
private val _uiState = MutableStateFlow(DeviceSettingsScreenContract.State())
val uiState: StateFlow<DeviceSettingsScreenContract.State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<DeviceSettingsScreenContract.Action>(extraBufferCapacity = 1)
val actions: SharedFlow<DeviceSettingsScreenContract.Action> = _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"
)
)
}
}
}
}

View File

@@ -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<Device?> = repository.observeDevice(deviceId)
}

View File

@@ -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<List<Device>> = repository.observeDevices()
}

View File

@@ -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<Boolean> =
repository.observeHasPendingMutations(deviceId)
}

View File

@@ -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<Unit> {
val trimmed = newName.trim()
if (trimmed.length < 3) {
return Result.failure(IllegalArgumentException("Name must be at least 3 characters"))
}
return repository.renameDevice(deviceId, trimmed)
}
}

View File

@@ -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<Unit> =
repository.setDataSharing(deviceId, enabled)
}

View File

@@ -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<Unit> =
repository.setLocation(deviceId, latitude, longitude)
suspend fun removeLocation(deviceId: String): Result<Unit> =
repository.removeLocation(deviceId)
}

View File

@@ -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<Unit> =
repository.triggerFirmwareUpdate(deviceId)
}

View File

@@ -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()
}

View File

@@ -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()
)
}
}

View File

@@ -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) {

View File

@@ -162,6 +162,9 @@
<string name="text_device_visibility_visible">Public</string>
<string name="text_device_visibility_private">Private</string>
<string name="text_device_visibility_not_registered">Register device to change visibility</string>
<string name="text_pending_sync">Changes will sync when online</string>
<string name="text_data_sharing">Data sharing</string>
<string name="button_firmware_update">Update firmware</string>
<string name="button_rename">Rename</string>
<string name="button_update">Update</string>
<string name="button_unregister">Unregister</string>

View File

@@ -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" }

View File

@@ -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)

View File

@@ -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<List<Device>> =
localDataSource.observeDevices()
override fun observeDevice(deviceId: String): Flow<Device?> =
localDataSource.observeDevice(deviceId)
override suspend fun renameDevice(deviceId: String, newName: String): Result<Unit> {
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<Unit> {
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<Unit> {
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<Unit> {
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<Unit> =
remoteDataSource.triggerFirmwareUpdate(deviceId)
override fun observeHasPendingMutations(deviceId: String): Flow<Boolean> =
localDataSource.observeHasPendingMutations(deviceId)
override suspend fun markOnlineStatusStale() {
localDataSource.markAllOnlineStatusStale()
}
override suspend fun refreshFromSubscription(devices: List<Device>) {
val withFreshness = devices.map { it.copy(onlineFreshness = OnlineFreshness.Fresh) }
localDataSource.upsertDevices(withFreshness)
}
}

View File

@@ -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<List<DeviceEntity>>
@Query("SELECT * FROM device WHERE id = :deviceId")
fun observeDevice(deviceId: String): Flow<DeviceEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertDevices(devices: List<DeviceEntity>)
@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<List<PendingMutationEntity>>
@Query("SELECT COUNT(*) FROM pending_mutation WHERE deviceId = :deviceId")
fun observePendingMutationCount(deviceId: String): Flow<Int>
@Query("SELECT * FROM pending_mutation ORDER BY createdAt ASC")
suspend fun getAllPending(): List<PendingMutationEntity>
@Query("DELETE FROM pending_mutation WHERE deviceId = :deviceId AND type = :type")
suspend fun deleteByDeviceAndType(deviceId: String, type: String)
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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<List<Device>> =
deviceDao.observeDevices().map { entities ->
entities.map { it.toDomain() }
}
fun observeDevice(deviceId: String): Flow<Device?> =
deviceDao.observeDevice(deviceId).map { it?.toDomain() }
suspend fun upsertDevices(devices: List<Device>) {
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<Boolean> =
pendingMutationDao.observePendingMutationCount(deviceId).map { it > 0 }
suspend fun getPendingMutations(): List<PendingMutation> =
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
)
}

View File

@@ -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
)

View File

@@ -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<List<Device>>
/**
* Execute rename mutation. Phase 1: No-op. Phase 2: Apollo mutation.
*/
suspend fun renameDevice(deviceId: String, newName: String): Result<Unit>
/**
* Execute set location mutation. Phase 1: No-op. Phase 2: Apollo mutation.
*/
suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit>
/**
* Execute remove location mutation. Phase 1: No-op. Phase 2: Apollo mutation.
*/
suspend fun removeLocation(deviceId: String): Result<Unit>
/**
* Execute data sharing mutation. Phase 1: No-op. Phase 2: Apollo mutation.
*/
suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit>
/**
* Execute firmware update. Phase 1: No-op. Phase 2: Apollo mutation.
*/
suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit>
}
/**
* Mock implementation for Phase 1.
*/
class MockDeviceRemoteDataSource @Inject constructor() : DeviceRemoteDataSource {
override fun observeDevicesSubscription(): Flow<List<Device>> = 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<Unit> =
Result.success(Unit)
override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit> =
Result.success(Unit)
override suspend fun removeLocation(deviceId: String): Result<Unit> =
Result.success(Unit)
override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit> =
Result.success(Unit)
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> =
Result.success(Unit)
}

View File

@@ -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()
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -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<List<Device>>
/**
* Observe a single device by ID.
*/
fun observeDevice(deviceId: String): Flow<Device?>
/**
* Rename a device. Optimistic update + enqueue for sync when online.
*
* @return Result success or failure
*/
suspend fun renameDevice(deviceId: String, newName: String): Result<Unit>
/**
* Set or update device location.
*
* @return Result success or failure
*/
suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit>
/**
* Remove device location.
*
* @return Result success or failure
*/
suspend fun removeLocation(deviceId: String): Result<Unit>
/**
* Enable or disable data sharing.
*
* @return Result success or failure
*/
suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit>
/**
* Trigger firmware update. Requires connectivity.
*
* @return Result success or failure
*/
suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit>
/**
* Check if there are pending mutations for a device.
*/
fun observeHasPendingMutations(deviceId: String): Flow<Boolean>
/**
* 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<Device>): Unit
}

View File

@@ -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
)