From 89ce2e1afafd359b75686a8d38a5cc24e3e133ef Mon Sep 17 00:00:00 2001 From: beetzung Date: Tue, 7 Apr 2026 18:04:42 +0200 Subject: [PATCH] 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 --- .../db3/airmq/features/manage/ManageScreen.kt | 3 +- .../features/manage/ManageScreenContract.kt | 1 + .../airmq/features/manage/ManageViewModel.kt | 30 +- sdk/src/main/graphql/AddLocation.graphql | 13 + sdk/src/main/graphql/ChangeLocation.graphql | 13 + sdk/src/main/graphql/DeviceAction.graphql | 5 + sdk/src/main/graphql/Devices.graphql | 30 ++ .../main/graphql/MyLocationsDevices.graphql | 38 +++ .../org/db3/airmq/sdk/auth/AuthServiceImpl.kt | 10 +- .../sdk/device/data/DeviceRepositoryImpl.kt | 20 +- .../data/local/DeviceCacheSyncedUserStore.kt | 11 + .../airmq/sdk/device/data/local/DeviceDao.kt | 19 ++ .../data/local/DeviceLocalDataSource.kt | 10 +- ...edPreferencesDeviceCacheSyncedUserStore.kt | 30 ++ .../data/remote/DeviceRemoteDataSource.kt | 278 +++++++++++------- .../data/remote/DeviceSubscriptionManager.kt | 12 +- .../db3/airmq/sdk/device/di/DeviceModule.kt | 12 +- .../sdk/device/domain/DeviceRepository.kt | 16 +- .../airmq/sdk/auth/FirebaseAuthServiceTest.kt | 44 ++- 19 files changed, 450 insertions(+), 145 deletions(-) create mode 100644 sdk/src/main/graphql/AddLocation.graphql create mode 100644 sdk/src/main/graphql/ChangeLocation.graphql create mode 100644 sdk/src/main/graphql/DeviceAction.graphql create mode 100644 sdk/src/main/graphql/Devices.graphql create mode 100644 sdk/src/main/graphql/MyLocationsDevices.graphql create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceCacheSyncedUserStore.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/SharedPreferencesDeviceCacheSyncedUserStore.kt diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt index cb8c09c..e16f444 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt @@ -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( diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreenContract.kt index bcd2edd..81136fc 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreenContract.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreenContract.kt @@ -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 = "", diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt index 72d8ed9..20b06fc 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt @@ -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) diff --git a/sdk/src/main/graphql/AddLocation.graphql b/sdk/src/main/graphql/AddLocation.graphql new file mode 100644 index 0000000..080f396 --- /dev/null +++ b/sdk/src/main/graphql/AddLocation.graphql @@ -0,0 +1,13 @@ +mutation AddLocation($input: LocationInput) { + addLocation(input: $input) { + _id + deviceId + name + city + latitude + longitude + isPublic + Sensorcom + Narodmon + } +} diff --git a/sdk/src/main/graphql/ChangeLocation.graphql b/sdk/src/main/graphql/ChangeLocation.graphql new file mode 100644 index 0000000..d929e1c --- /dev/null +++ b/sdk/src/main/graphql/ChangeLocation.graphql @@ -0,0 +1,13 @@ +mutation ChangeLocation($input: ChgLocation) { + changeLocation(input: $input) { + _id + deviceId + name + city + latitude + longitude + isPublic + Sensorcom + Narodmon + } +} diff --git a/sdk/src/main/graphql/DeviceAction.graphql b/sdk/src/main/graphql/DeviceAction.graphql new file mode 100644 index 0000000..908ffef --- /dev/null +++ b/sdk/src/main/graphql/DeviceAction.graphql @@ -0,0 +1,5 @@ +mutation DeviceAction($input: SendAction) { + deviceAction(input: $input) { + success + } +} diff --git a/sdk/src/main/graphql/Devices.graphql b/sdk/src/main/graphql/Devices.graphql new file mode 100644 index 0000000..15df4e5 --- /dev/null +++ b/sdk/src/main/graphql/Devices.graphql @@ -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 + } + } +} diff --git a/sdk/src/main/graphql/MyLocationsDevices.graphql b/sdk/src/main/graphql/MyLocationsDevices.graphql new file mode 100644 index 0000000..5d2b231 --- /dev/null +++ b/sdk/src/main/graphql/MyLocationsDevices.graphql @@ -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 + } + } + } + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt index 444d917..990c5d0 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt @@ -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 = 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( diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt index eb409b3..558267b 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt @@ -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> = @@ -203,8 +205,20 @@ class DeviceRepositoryImpl @Inject constructor( localDataSource.markAllOnlineStatusStale() } - override suspend fun refreshFromSubscription(devices: List) { + override suspend fun refreshFromSubscription(accountUserId: String, devices: List) { 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() + } } } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceCacheSyncedUserStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceCacheSyncedUserStore.kt new file mode 100644 index 0000000..000d335 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceCacheSyncedUserStore.kt @@ -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() +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt index 6e7090b..5435179 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt @@ -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) { + 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() } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt index a9c0585..9ff52b8 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt @@ -27,13 +27,13 @@ class DeviceLocalDataSource @Inject constructor( fun observeDevice(deviceId: String): Flow = deviceDao.observeDevice(deviceId).map { it?.toDomain() } - suspend fun upsertDevices(devices: List) { - val entities = devices.map { it.toEntity() } - deviceDao.upsertDevices(entities) + suspend fun replaceDevicesFromSubscription(devices: List) { + 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) { diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/SharedPreferencesDeviceCacheSyncedUserStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/SharedPreferencesDeviceCacheSyncedUserStore.kt new file mode 100644 index 0000000..02393ad --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/SharedPreferencesDeviceCacheSyncedUserStore.kt @@ -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" + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt index 57740cf..fc835fd 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt @@ -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> - - /** - * Execute rename mutation. Phase 1: No-op. Phase 2: Apollo mutation. - */ suspend fun renameDevice(deviceId: String, newName: String): Result - - /** - * Execute set location mutation. Phase 1: No-op. Phase 2: Apollo mutation. - */ suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result - - /** - * Execute remove location mutation. Phase 1: No-op. Phase 2: Apollo mutation. - */ suspend fun removeLocation(deviceId: String): Result - - /** - * Execute data sharing mutation. Phase 1: No-op. Phase 2: Apollo mutation. - */ suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result - - /** - * Execute Narodmon.ru toggle. Phase 1: No-op. Phase 2: Apollo mutation. - */ suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result - - /** - * Execute Sensor.community (Luftdata) toggle. Phase 1: No-op. Phase 2: Apollo mutation. - */ suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result - - /** - * Execute set visibility (publish/hide) mutation. Phase 1: No-op. Phase 2: Apollo mutation. - */ suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result - - /** - * Execute firmware update. Phase 1: No-op. Phase 2: Apollo mutation. - */ suspend fun triggerFirmwareUpdate(deviceId: String): Result } -/** - * Mock implementation for Phase 1. - */ -class MockDeviceRemoteDataSource @Inject constructor() : DeviceRemoteDataSource { +class ApolloDeviceRemoteDataSource @Inject constructor( + private val apolloClient: ApolloClient +) : DeviceRemoteDataSource { override fun observeDevicesSubscription(): Flow> = 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 = - Result.success(Unit) + override suspend fun renameDevice(deviceId: String, newName: String): Result = 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 = - Result.success(Unit) + override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result = 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 = - Result.success(Unit) + Result.failure(UnsupportedOperationException("removeLocation is not supported by current API schema.")) - override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result = - Result.success(Unit) + override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result = runCatching { + val locationId = resolveLocationId(deviceId) + mutateLocation(locationId) { current -> current.copy(Sensorcom = Optional.Present(enabled)) } + } - override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result = - Result.success(Unit) + override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result = runCatching { + val locationId = resolveLocationId(deviceId) + mutateLocation(locationId) { current -> current.copy(Narodmon = Optional.Present(enabled)) } + } override suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result = - Result.success(Unit) + Result.failure(UnsupportedOperationException("Luftdata toggle is not exposed by current API schema.")) - override suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result = - Result.success(Unit) + override suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result = 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 = - Result.success(Unit) + override suspend fun triggerFirmwareUpdate(deviceId: String): Result = 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 { + 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 { + 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 + } } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceSubscriptionManager.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceSubscriptionManager.kt index 3aab14b..2c29fa9 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceSubscriptionManager.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceSubscriptionManager.kt @@ -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() } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt index a0d4259..e707c7d 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt @@ -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 } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt index 0aeb741..98e829b 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt @@ -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): Unit + suspend fun refreshFromSubscription(accountUserId: String, devices: List): 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) } diff --git a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt index 638023e..020b93b 100644 --- a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt +++ b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt @@ -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(relaxed = true) + val subManager = mockk(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() 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() 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() 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(relaxed = true) + val subManager = mockk(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()) } 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(