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:
2026-04-07 18:04:42 +02:00
parent 0a79ee5e04
commit 89ce2e1afa
19 changed files with 450 additions and 145 deletions

View File

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

View File

@@ -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 = "",

View File

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

View File

@@ -0,0 +1,13 @@
mutation AddLocation($input: LocationInput) {
addLocation(input: $input) {
_id
deviceId
name
city
latitude
longitude
isPublic
Sensorcom
Narodmon
}
}

View File

@@ -0,0 +1,13 @@
mutation ChangeLocation($input: ChgLocation) {
changeLocation(input: $input) {
_id
deviceId
name
city
latitude
longitude
isPublic
Sensorcom
Narodmon
}
}

View File

@@ -0,0 +1,5 @@
mutation DeviceAction($input: SendAction) {
deviceAction(input: $input) {
success
}
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 triggerFirmwareUpdate(deviceId: String): 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> = 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
}
}

View File

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

View File

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

View File

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

View File

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