fix(manage): use account-scoped device sync and remove mock device source
Preserve Manage device state across resume while preventing cross-account cache leaks by tracking synced user IDs and clearing stale local device data on account changes. Replace the mock device remote data source with Apollo-backed API queries/mutations using me.locations so Manage only shows devices belonging to the authenticated user. Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
package org.db3.airmq.features.manage
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -68,7 +67,6 @@ fun ManageScreen(
|
||||
viewModel: ManageViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
Log.d("MANAGE_DEBUG", uiState.toString())
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, viewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
@@ -413,6 +411,7 @@ private fun ManageScreenAuthorizedPreview() {
|
||||
ManageScreenContent(
|
||||
uiState = State(
|
||||
isAuthorized = true,
|
||||
userId = "preview",
|
||||
userName = "User",
|
||||
userEmail = "user@example.com",
|
||||
devices = listOf(
|
||||
|
||||
@@ -11,6 +11,7 @@ object ManageScreenContract {
|
||||
|
||||
data class State(
|
||||
val isAuthorized: Boolean = false,
|
||||
val userId: String = "",
|
||||
val userName: String = "",
|
||||
val userEmail: String = "",
|
||||
val devicesLabel: String = "",
|
||||
|
||||
@@ -21,8 +21,8 @@ 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.DeviceRepository
|
||||
import org.db3.airmq.sdk.device.domain.OnlineStatus
|
||||
|
||||
@HiltViewModel
|
||||
@@ -30,6 +30,7 @@ class ManageViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val authService: AuthService,
|
||||
private val getMyDevicesUseCase: GetMyDevicesUseCase,
|
||||
private val deviceRepository: DeviceRepository,
|
||||
private val subscriptionManager: DeviceSubscriptionManager
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -47,7 +48,7 @@ class ManageViewModel @Inject constructor(
|
||||
if (session?.isAuthenticated == true) {
|
||||
val user = session
|
||||
_uiState.update { state ->
|
||||
if (state.isAuthorized) {
|
||||
if (state.isAuthorized && state.userId == session.userId) {
|
||||
state.copy(
|
||||
devices = devices.map { device -> device.toDeviceItem(appContext) }
|
||||
)
|
||||
@@ -75,8 +76,20 @@ class ManageViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val session = authService.getUser()
|
||||
if (session?.isAuthenticated == true) {
|
||||
subscriptionManager.start()
|
||||
_uiState.value = authorizedState(session)
|
||||
deviceRepository.ensureLocalDevicesMatchAccount(session.userId)
|
||||
subscriptionManager.start(session.userId)
|
||||
_uiState.update { prev ->
|
||||
val sameUser = prev.isAuthorized && prev.userId == session.userId
|
||||
State(
|
||||
isAuthorized = true,
|
||||
userId = session.userId,
|
||||
userName = session.displayName
|
||||
?: appContext.getString(R.string.text_anonymous_user),
|
||||
userEmail = session.email ?: "",
|
||||
devicesLabel = "",
|
||||
devices = if (sameUser) prev.devices else emptyList()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
subscriptionManager.stop()
|
||||
_uiState.value = anonymousState()
|
||||
@@ -86,19 +99,12 @@ class ManageViewModel @Inject constructor(
|
||||
|
||||
private fun anonymousState(): State = State(
|
||||
isAuthorized = false,
|
||||
userId = "",
|
||||
userName = appContext.getString(R.string.text_anonymous_user),
|
||||
userEmail = appContext.getString(R.string.text_please_sign_in),
|
||||
devicesLabel = appContext.getString(R.string.text_sign_in_small)
|
||||
)
|
||||
|
||||
private fun authorizedState(user: User): State = State(
|
||||
isAuthorized = true,
|
||||
userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user),
|
||||
userEmail = user.email ?: "",
|
||||
devicesLabel = "",
|
||||
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)
|
||||
|
||||
13
sdk/src/main/graphql/AddLocation.graphql
Normal file
13
sdk/src/main/graphql/AddLocation.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation AddLocation($input: LocationInput) {
|
||||
addLocation(input: $input) {
|
||||
_id
|
||||
deviceId
|
||||
name
|
||||
city
|
||||
latitude
|
||||
longitude
|
||||
isPublic
|
||||
Sensorcom
|
||||
Narodmon
|
||||
}
|
||||
}
|
||||
13
sdk/src/main/graphql/ChangeLocation.graphql
Normal file
13
sdk/src/main/graphql/ChangeLocation.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation ChangeLocation($input: ChgLocation) {
|
||||
changeLocation(input: $input) {
|
||||
_id
|
||||
deviceId
|
||||
name
|
||||
city
|
||||
latitude
|
||||
longitude
|
||||
isPublic
|
||||
Sensorcom
|
||||
Narodmon
|
||||
}
|
||||
}
|
||||
5
sdk/src/main/graphql/DeviceAction.graphql
Normal file
5
sdk/src/main/graphql/DeviceAction.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation DeviceAction($input: SendAction) {
|
||||
deviceAction(input: $input) {
|
||||
success
|
||||
}
|
||||
}
|
||||
30
sdk/src/main/graphql/Devices.graphql
Normal file
30
sdk/src/main/graphql/Devices.graphql
Normal file
@@ -0,0 +1,30 @@
|
||||
query Devices($filter: DeviceFilter) {
|
||||
devices(filter: $filter) {
|
||||
_id
|
||||
model {
|
||||
name
|
||||
}
|
||||
Narodmon
|
||||
Sensorcom
|
||||
locationId
|
||||
status {
|
||||
isOnline
|
||||
local_ip
|
||||
}
|
||||
hardware {
|
||||
firmwareVersion
|
||||
configVersion
|
||||
}
|
||||
location {
|
||||
_id
|
||||
name
|
||||
city
|
||||
latitude
|
||||
longitude
|
||||
isPublic
|
||||
Sensorcom
|
||||
Narodmon
|
||||
isOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
38
sdk/src/main/graphql/MyLocationsDevices.graphql
Normal file
38
sdk/src/main/graphql/MyLocationsDevices.graphql
Normal file
@@ -0,0 +1,38 @@
|
||||
query MyLocationsDevices {
|
||||
me {
|
||||
name
|
||||
locations {
|
||||
_id
|
||||
name
|
||||
city
|
||||
latitude
|
||||
longitude
|
||||
isPublic
|
||||
Sensorcom
|
||||
Narodmon
|
||||
isOnline
|
||||
deviceId
|
||||
mean_last(input: { interval_h: 3 }) {
|
||||
time
|
||||
Temp
|
||||
}
|
||||
device {
|
||||
_id
|
||||
model {
|
||||
name
|
||||
}
|
||||
Narodmon
|
||||
Sensorcom
|
||||
locationId
|
||||
status {
|
||||
isOnline
|
||||
local_ip
|
||||
}
|
||||
hardware {
|
||||
firmwareVersion
|
||||
configVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import org.db3.airmq.sdk.LoginLocalMutation
|
||||
import org.db3.airmq.sdk.RegisterMutation
|
||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||
import org.db3.airmq.sdk.auth.model.User
|
||||
import org.db3.airmq.sdk.device.data.remote.DeviceSubscriptionManager
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import org.db3.airmq.sdk.type.LocalAuthInput
|
||||
import org.db3.airmq.sdk.type.RegisterInput
|
||||
|
||||
@@ -17,7 +19,9 @@ class FirebaseAuthService @Inject constructor(
|
||||
private val firebaseSessionManager: FirebaseSessionManager,
|
||||
private val apolloClient: ApolloClient,
|
||||
private val apiTokenStore: ApiTokenStore,
|
||||
private val localEmailAuthStore: LocalEmailAuthStore
|
||||
private val localEmailAuthStore: LocalEmailAuthStore,
|
||||
private val deviceRepository: DeviceRepository,
|
||||
private val deviceSubscriptionManager: DeviceSubscriptionManager
|
||||
) : AuthService {
|
||||
|
||||
override suspend fun getUser(): User? {
|
||||
@@ -77,9 +81,11 @@ class FirebaseAuthService @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun signOut(): Result<Unit> = runCatching {
|
||||
deviceSubscriptionManager.stop()
|
||||
firebaseSessionManager.signOut()
|
||||
apiTokenStore.clearToken().getOrThrow()
|
||||
localEmailAuthStore.clearProfile().getOrThrow()
|
||||
deviceRepository.clearLocalDeviceData()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +116,8 @@ class FirebaseAuthService @Inject constructor(
|
||||
?: error("Backend auth succeeded without API token.")
|
||||
val resolvedUserId = userId?.takeIf { it.isNotBlank() }
|
||||
?: error("Backend auth succeeded without user id.")
|
||||
deviceSubscriptionManager.stop()
|
||||
deviceRepository.clearLocalDeviceData()
|
||||
firebaseSessionManager.signOut()
|
||||
apiTokenStore.saveToken(resolvedToken).getOrThrow()
|
||||
localEmailAuthStore.saveProfile(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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.DeviceCacheSyncedUserStore
|
||||
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
|
||||
@@ -18,7 +19,8 @@ import javax.inject.Inject
|
||||
*/
|
||||
class DeviceRepositoryImpl @Inject constructor(
|
||||
private val localDataSource: DeviceLocalDataSource,
|
||||
private val remoteDataSource: DeviceRemoteDataSource
|
||||
private val remoteDataSource: DeviceRemoteDataSource,
|
||||
private val deviceCacheSyncedUserStore: DeviceCacheSyncedUserStore
|
||||
) : DeviceRepository {
|
||||
|
||||
override fun observeDevices(): Flow<List<Device>> =
|
||||
@@ -203,8 +205,20 @@ class DeviceRepositoryImpl @Inject constructor(
|
||||
localDataSource.markAllOnlineStatusStale()
|
||||
}
|
||||
|
||||
override suspend fun refreshFromSubscription(devices: List<Device>) {
|
||||
override suspend fun refreshFromSubscription(accountUserId: String, devices: List<Device>) {
|
||||
val withFreshness = devices.map { it.copy(onlineFreshness = OnlineFreshness.Fresh) }
|
||||
localDataSource.upsertDevices(withFreshness)
|
||||
localDataSource.replaceDevicesFromSubscription(withFreshness)
|
||||
deviceCacheSyncedUserStore.setSyncedUserId(accountUserId)
|
||||
}
|
||||
|
||||
override suspend fun clearLocalDeviceData() {
|
||||
localDataSource.clearAllLocalDeviceData()
|
||||
deviceCacheSyncedUserStore.clear()
|
||||
}
|
||||
|
||||
override suspend fun ensureLocalDevicesMatchAccount(accountUserId: String) {
|
||||
if (deviceCacheSyncedUserStore.getSyncedUserId() != accountUserId) {
|
||||
clearLocalDeviceData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.db3.airmq.sdk.device.data.local
|
||||
|
||||
/**
|
||||
* Persists which account user id the local device table was last synced for via subscription.
|
||||
* Used to detect stale rows after account switch or app upgrade without going through login again.
|
||||
*/
|
||||
interface DeviceCacheSyncedUserStore {
|
||||
fun getSyncedUserId(): String?
|
||||
fun setSyncedUserId(userId: String)
|
||||
fun clear()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
@@ -47,6 +48,21 @@ interface DeviceDao {
|
||||
|
||||
@Query("SELECT * FROM device WHERE id = :deviceId")
|
||||
suspend fun getDevice(deviceId: String): DeviceEntity?
|
||||
|
||||
@Query("DELETE FROM device")
|
||||
suspend fun deleteAllDevices()
|
||||
|
||||
/**
|
||||
* Subscription payload is the full device list for the current session; replace local rows so
|
||||
* an empty list removes devices from a previous account.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceAllDevices(devices: List<DeviceEntity>) {
|
||||
deleteAllDevices()
|
||||
if (devices.isNotEmpty()) {
|
||||
upsertDevices(devices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
@@ -69,4 +85,7 @@ interface PendingMutationDao {
|
||||
|
||||
@Query("DELETE FROM pending_mutation WHERE deviceId = :deviceId AND type = :type")
|
||||
suspend fun deleteByDeviceAndType(deviceId: String, type: String)
|
||||
|
||||
@Query("DELETE FROM pending_mutation")
|
||||
suspend fun deleteAllPendingMutations()
|
||||
}
|
||||
|
||||
@@ -27,13 +27,13 @@ class DeviceLocalDataSource @Inject constructor(
|
||||
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 replaceDevicesFromSubscription(devices: List<Device>) {
|
||||
deviceDao.replaceAllDevices(devices.map { it.toEntity() })
|
||||
}
|
||||
|
||||
suspend fun upsertDevice(device: Device) {
|
||||
deviceDao.upsertDevice(device.toEntity())
|
||||
suspend fun clearAllLocalDeviceData() {
|
||||
deviceDao.deleteAllDevices()
|
||||
pendingMutationDao.deleteAllPendingMutations()
|
||||
}
|
||||
|
||||
suspend fun updateName(deviceId: String, newName: String) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.db3.airmq.sdk.device.data.local
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SharedPreferencesDeviceCacheSyncedUserStore @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) : DeviceCacheSyncedUserStore {
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override fun getSyncedUserId(): String? =
|
||||
prefs.getString(KEY_SYNCED_USER_ID, null)?.takeIf { it.isNotBlank() }
|
||||
|
||||
override fun setSyncedUserId(userId: String) {
|
||||
prefs.edit().putString(KEY_SYNCED_USER_ID, userId).apply()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().remove(KEY_SYNCED_USER_ID).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "airmq_device_sync"
|
||||
const val KEY_SYNCED_USER_ID = "synced_user_id"
|
||||
}
|
||||
}
|
||||
@@ -1,142 +1,208 @@
|
||||
package org.db3.airmq.sdk.device.data.remote
|
||||
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.api.Optional
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.db3.airmq.sdk.AddLocationMutation
|
||||
import org.db3.airmq.sdk.ChangeLocationMutation
|
||||
import org.db3.airmq.sdk.DeviceActionMutation
|
||||
import org.db3.airmq.sdk.MyLocationsDevicesQuery
|
||||
import org.db3.airmq.sdk.device.domain.Device
|
||||
import org.db3.airmq.sdk.device.domain.DeviceModel
|
||||
import org.db3.airmq.sdk.device.domain.OnlineFreshness
|
||||
import javax.inject.Inject
|
||||
import org.db3.airmq.sdk.type.ChgLocation
|
||||
import org.db3.airmq.sdk.type.LocationInput
|
||||
import org.db3.airmq.sdk.type.SendAction
|
||||
|
||||
/**
|
||||
* 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 Narodmon.ru toggle. Phase 1: No-op. Phase 2: Apollo mutation.
|
||||
*/
|
||||
suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit>
|
||||
|
||||
/**
|
||||
* Execute Sensor.community (Luftdata) toggle. Phase 1: No-op. Phase 2: Apollo mutation.
|
||||
*/
|
||||
suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result<Unit>
|
||||
|
||||
/**
|
||||
* Execute set visibility (publish/hide) mutation. Phase 1: No-op. Phase 2: Apollo mutation.
|
||||
*/
|
||||
suspend fun setVisibility(deviceId: String, isPublic: 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 {
|
||||
class ApolloDeviceRemoteDataSource @Inject constructor(
|
||||
private val apolloClient: ApolloClient
|
||||
) : DeviceRemoteDataSource {
|
||||
|
||||
override fun observeDevicesSubscription(): Flow<List<Device>> = flow {
|
||||
// Emit mock device list
|
||||
val mockDevices = listOf(
|
||||
Device(
|
||||
id = "device-1",
|
||||
name = "AirMQ #42",
|
||||
model = DeviceModel.Mobile,
|
||||
firmwareVersion = "1.0",
|
||||
deviceAddress = "192.168.1.100",
|
||||
configVersion = "42",
|
||||
isNarodmonOn = true,
|
||||
isLuftdataOn = false,
|
||||
locationId = "loc-1",
|
||||
latitude = 53.9,
|
||||
longitude = 27.5,
|
||||
isPublic = false,
|
||||
city = "Minsk",
|
||||
dataSharingEnabled = true,
|
||||
isOnline = true,
|
||||
onlineFreshness = OnlineFreshness.Fresh,
|
||||
ownerId = "user-1"
|
||||
),
|
||||
Device(
|
||||
id = "device-2",
|
||||
name = "AirMQ #17",
|
||||
model = DeviceModel.Mobile,
|
||||
firmwareVersion = "1.0",
|
||||
deviceAddress = "192.168.1.101",
|
||||
configVersion = "41",
|
||||
isNarodmonOn = false,
|
||||
isLuftdataOn = false,
|
||||
locationId = null,
|
||||
latitude = null,
|
||||
longitude = null,
|
||||
isPublic = false,
|
||||
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)
|
||||
while (currentCoroutineContext().isActive) {
|
||||
emit(fetchDevices())
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun renameDevice(deviceId: String, newName: String): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
override suspend fun renameDevice(deviceId: String, newName: String): Result<Unit> = runCatching {
|
||||
val location = fetchLocationByDeviceId(deviceId)
|
||||
?: error("Device not found: $deviceId")
|
||||
mutateLocation(location._id) { current ->
|
||||
current.copy(name = Optional.Present(newName))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit> = runCatching {
|
||||
val locationId = fetchLocationByDeviceId(deviceId)?._id
|
||||
if (locationId != null) {
|
||||
mutateLocation(locationId) { current ->
|
||||
current.copy(
|
||||
latitude = Optional.Present(latitude),
|
||||
longitude = Optional.Present(longitude)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val response = apolloClient
|
||||
.mutation(
|
||||
AddLocationMutation(
|
||||
input = Optional.Present(
|
||||
LocationInput(
|
||||
deviceId = Optional.Present(deviceId),
|
||||
name = Optional.Present("Device $deviceId"),
|
||||
latitude = Optional.Present(latitude),
|
||||
longitude = Optional.Present(longitude),
|
||||
isPublic = Optional.Present(false)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
|
||||
response.data?.addLocation?._id ?: error("addLocation returned no location id.")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeLocation(deviceId: String): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
Result.failure(UnsupportedOperationException("removeLocation is not supported by current API schema."))
|
||||
|
||||
override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit> = runCatching {
|
||||
val locationId = resolveLocationId(deviceId)
|
||||
mutateLocation(locationId) { current -> current.copy(Sensorcom = Optional.Present(enabled)) }
|
||||
}
|
||||
|
||||
override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit> = runCatching {
|
||||
val locationId = resolveLocationId(deviceId)
|
||||
mutateLocation(locationId) { current -> current.copy(Narodmon = Optional.Present(enabled)) }
|
||||
}
|
||||
|
||||
override suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
Result.failure(UnsupportedOperationException("Luftdata toggle is not exposed by current API schema."))
|
||||
|
||||
override suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
override suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result<Unit> = runCatching {
|
||||
val location = fetchLocationByDeviceId(deviceId)
|
||||
?: error("Device not found: $deviceId")
|
||||
val locationId = location._id
|
||||
val locationName = location.name
|
||||
val response = apolloClient
|
||||
.mutation(
|
||||
AddLocationMutation(
|
||||
input = Optional.Present(
|
||||
LocationInput(
|
||||
_id = Optional.Present(locationId),
|
||||
isPublic = Optional.Present(isPublic),
|
||||
name = Optional.Present(locationName)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
|
||||
response.data?.addLocation?._id ?: error("addLocation update returned no location id.")
|
||||
}
|
||||
|
||||
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> = runCatching {
|
||||
val response = apolloClient
|
||||
.mutation(
|
||||
DeviceActionMutation(
|
||||
input = Optional.Present(
|
||||
SendAction(
|
||||
deviceId = deviceId,
|
||||
fwUpgrade = Optional.Present(true)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
|
||||
val success = response.data?.deviceAction?.success ?: false
|
||||
if (!success) error("Firmware update request was rejected by API.")
|
||||
}
|
||||
|
||||
private suspend fun fetchDevices(): List<Device> {
|
||||
val response = apolloClient.query(MyLocationsDevicesQuery()).execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
|
||||
return response.data?.me?.locations.orEmpty().mapNotNull { it?.toDomain() }
|
||||
}
|
||||
|
||||
private suspend fun fetchLocationByDeviceId(deviceId: String): MyLocationsDevicesQuery.Location? =
|
||||
fetchLocationsRaw().firstOrNull { location ->
|
||||
location.device?._id == deviceId || location.deviceId == deviceId
|
||||
}
|
||||
|
||||
private suspend fun fetchLocationsRaw(): List<MyLocationsDevicesQuery.Location> {
|
||||
val response = apolloClient.query(MyLocationsDevicesQuery()).execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
|
||||
return response.data?.me?.locations.orEmpty().mapNotNull { it }
|
||||
}
|
||||
|
||||
private suspend fun resolveLocationId(deviceId: String): String {
|
||||
val location = fetchLocationByDeviceId(deviceId)
|
||||
?: error("Device not found: $deviceId")
|
||||
return location._id
|
||||
}
|
||||
|
||||
private suspend fun mutateLocation(
|
||||
locationId: String,
|
||||
update: (ChgLocation) -> ChgLocation
|
||||
) {
|
||||
val input = update(ChgLocation(_id = locationId))
|
||||
val response = apolloClient
|
||||
.mutation(ChangeLocationMutation(input = Optional.Present(input)))
|
||||
.execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
|
||||
response.data?.changeLocation?._id ?: error("changeLocation returned no location id.")
|
||||
}
|
||||
|
||||
private fun MyLocationsDevicesQuery.Location.toDomain(): Device? {
|
||||
val id = device?._id ?: deviceId ?: return null
|
||||
val modelName = device?.model?.name
|
||||
val isOnlineResolved = device?.status?.isOnline ?: isOnline ?: false
|
||||
return Device(
|
||||
id = id,
|
||||
name = name.takeIf { it.isNotBlank() } ?: id,
|
||||
model = DeviceModel.fromString(modelName),
|
||||
firmwareVersion = device?.hardware?.firmwareVersion ?: "",
|
||||
deviceAddress = device?.status?.local_ip ?: "",
|
||||
configVersion = device?.hardware?.configVersion ?: "",
|
||||
isNarodmonOn = device?.Narodmon ?: Narodmon ?: false,
|
||||
isLuftdataOn = false,
|
||||
locationId = _id,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
isPublic = isPublic ?: false,
|
||||
city = city,
|
||||
dataSharingEnabled = device?.Sensorcom ?: Sensorcom ?: false,
|
||||
isOnline = isOnlineResolved,
|
||||
onlineFreshness = OnlineFreshness.Fresh,
|
||||
ownerId = null
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POLL_INTERVAL_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,16 @@ class DeviceSubscriptionManager @Inject constructor(
|
||||
) {
|
||||
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var isSubscribed = false
|
||||
private var activeAccountUserId: String? = null
|
||||
|
||||
/**
|
||||
* Start the subscription. Call when user is authenticated.
|
||||
* Start the subscription for [accountUserId]. Restarts the flow if the account changed.
|
||||
*/
|
||||
fun start() {
|
||||
if (isSubscribed) return
|
||||
fun start(accountUserId: String) {
|
||||
if (isSubscribed && activeAccountUserId == accountUserId) return
|
||||
stop()
|
||||
isSubscribed = true
|
||||
activeAccountUserId = accountUserId
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
remoteDataSource.observeDevicesSubscription()
|
||||
.catch {
|
||||
@@ -39,7 +42,7 @@ class DeviceSubscriptionManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
.onEach { devices ->
|
||||
repository.refreshFromSubscription(devices)
|
||||
repository.refreshFromSubscription(accountUserId, devices)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
@@ -49,6 +52,7 @@ class DeviceSubscriptionManager @Inject constructor(
|
||||
*/
|
||||
fun stop() {
|
||||
isSubscribed = false
|
||||
activeAccountUserId = null
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,13 @@ import javax.inject.Singleton
|
||||
import org.db3.airmq.sdk.device.data.DeviceRepositoryImpl
|
||||
import org.db3.airmq.sdk.device.data.local.DEVICE_DB_MIGRATION_1_2
|
||||
import org.db3.airmq.sdk.device.data.local.DEVICE_DB_MIGRATION_2_3
|
||||
import org.db3.airmq.sdk.device.data.local.DeviceCacheSyncedUserStore
|
||||
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.local.SharedPreferencesDeviceCacheSyncedUserStore
|
||||
import org.db3.airmq.sdk.device.data.remote.ApolloDeviceRemoteDataSource
|
||||
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
|
||||
@@ -52,5 +54,11 @@ abstract class DeviceBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceRemoteDataSource(impl: MockDeviceRemoteDataSource): DeviceRemoteDataSource
|
||||
abstract fun bindDeviceRemoteDataSource(impl: ApolloDeviceRemoteDataSource): DeviceRemoteDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceCacheSyncedUserStore(
|
||||
impl: SharedPreferencesDeviceCacheSyncedUserStore
|
||||
): DeviceCacheSyncedUserStore
|
||||
}
|
||||
|
||||
@@ -81,7 +81,19 @@ interface DeviceRepository {
|
||||
suspend fun markOnlineStatusStale(): Unit
|
||||
|
||||
/**
|
||||
* Refresh devices from subscription payload. Internal use by DeviceSubscriptionManager.
|
||||
* Replace local devices with subscription payload for [accountUserId].
|
||||
* Internal use by DeviceSubscriptionManager.
|
||||
*/
|
||||
suspend fun refreshFromSubscription(devices: List<Device>): Unit
|
||||
suspend fun refreshFromSubscription(accountUserId: String, devices: List<Device>): Unit
|
||||
|
||||
/**
|
||||
* Remove all cached devices and pending mutations (e.g. sign-out or new session).
|
||||
*/
|
||||
suspend fun clearLocalDeviceData()
|
||||
|
||||
/**
|
||||
* If local device rows were last synced for a different account (or never), clear the cache.
|
||||
* Call while authenticated before starting the device subscription (covers cold start after account switch).
|
||||
*/
|
||||
suspend fun ensureLocalDevicesMatchAccount(accountUserId: String)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,18 @@ import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.api.ApolloResponse
|
||||
import com.benasher44.uuid.uuid4
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.db3.airmq.sdk.AuthGoogleAppMutation
|
||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||
import org.db3.airmq.sdk.auth.model.User
|
||||
import org.db3.airmq.sdk.device.data.remote.DeviceSubscriptionManager
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -28,7 +32,7 @@ class FirebaseAuthServiceTest {
|
||||
val tokenStore = FakeApiTokenStore()
|
||||
val localEmailStore = FakeLocalEmailAuthStore()
|
||||
mockAuthGoogleAppError(apolloClient, IllegalStateException("backend failed"))
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||
|
||||
@@ -61,7 +65,9 @@ class FirebaseAuthServiceTest {
|
||||
uuid4()
|
||||
).data(mutationData).build()
|
||||
coEvery { mockCall.execute() } returns apolloResponse
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
val deviceRepo = mockk<DeviceRepository>(relaxed = true)
|
||||
val subManager = mockk<DeviceSubscriptionManager>(relaxed = true)
|
||||
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore, deviceRepo, subManager)
|
||||
|
||||
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||
|
||||
@@ -70,6 +76,8 @@ class FirebaseAuthServiceTest {
|
||||
assertEquals("backend-id", localEmailStore.getProfile()?.userId)
|
||||
assertEquals("u@test.dev", localEmailStore.getProfile()?.email)
|
||||
assertEquals(1, sessionManager.signOutCallCount)
|
||||
verify(exactly = 1) { subManager.stop() }
|
||||
coVerify(exactly = 1) { deviceRepo.clearLocalDeviceData() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,7 +89,7 @@ class FirebaseAuthServiceTest {
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||
val localEmailStore = FakeLocalEmailAuthStore()
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
assertTrue(service.isAuthenticated())
|
||||
|
||||
@@ -97,7 +105,7 @@ class FirebaseAuthServiceTest {
|
||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||
saveProfile("backend-id", "e@mail.test", "Name").getOrThrow()
|
||||
}
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
assertTrue(service.isAuthenticated())
|
||||
}
|
||||
@@ -110,7 +118,7 @@ class FirebaseAuthServiceTest {
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore(storedToken = null)
|
||||
val localEmailStore = FakeLocalEmailAuthStore()
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
assertNull(service.getUser())
|
||||
}
|
||||
@@ -123,7 +131,7 @@ class FirebaseAuthServiceTest {
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||
val localEmailStore = FakeLocalEmailAuthStore()
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
val user = service.getUser()
|
||||
assertNotNull(user)
|
||||
@@ -139,7 +147,7 @@ class FirebaseAuthServiceTest {
|
||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||
saveProfile("bid", "local@test.dev", "Local User").getOrThrow()
|
||||
}
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
val user = service.getUser()
|
||||
assertNotNull(user)
|
||||
@@ -157,11 +165,15 @@ class FirebaseAuthServiceTest {
|
||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||
saveProfile("id", "a@b.c", null).getOrThrow()
|
||||
}
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
val deviceRepo = mockk<DeviceRepository>(relaxed = true)
|
||||
val subManager = mockk<DeviceSubscriptionManager>(relaxed = true)
|
||||
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore, deviceRepo, subManager)
|
||||
|
||||
assertTrue(service.signOut().isSuccess)
|
||||
assertNull(tokenStore.storedToken)
|
||||
assertNull(localEmailStore.getProfile())
|
||||
verify(exactly = 1) { subManager.stop() }
|
||||
coVerify(exactly = 1) { deviceRepo.clearLocalDeviceData() }
|
||||
}
|
||||
|
||||
private fun mockAuthGoogleAppError(apolloClient: ApolloClient, error: Throwable) {
|
||||
@@ -169,6 +181,22 @@ class FirebaseAuthServiceTest {
|
||||
every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
|
||||
coEvery { mockCall.execute() } throws error
|
||||
}
|
||||
|
||||
private fun firebaseAuthService(
|
||||
sessionManager: FirebaseSessionManager,
|
||||
apolloClient: ApolloClient,
|
||||
tokenStore: ApiTokenStore,
|
||||
localEmailStore: LocalEmailAuthStore,
|
||||
deviceRepository: DeviceRepository = mockk(relaxed = true),
|
||||
subscriptionManager: DeviceSubscriptionManager = mockk(relaxed = true)
|
||||
): FirebaseAuthService = FirebaseAuthService(
|
||||
sessionManager,
|
||||
apolloClient,
|
||||
tokenStore,
|
||||
localEmailStore,
|
||||
deviceRepository,
|
||||
subscriptionManager
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeFirebaseSessionManager(
|
||||
|
||||
Reference in New Issue
Block a user