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