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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user