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
|
package org.db3.airmq.features.manage
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -68,7 +67,6 @@ fun ManageScreen(
|
|||||||
viewModel: ManageViewModel = hiltViewModel()
|
viewModel: ManageViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
Log.d("MANAGE_DEBUG", uiState.toString())
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner, viewModel) {
|
DisposableEffect(lifecycleOwner, viewModel) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
@@ -413,6 +411,7 @@ private fun ManageScreenAuthorizedPreview() {
|
|||||||
ManageScreenContent(
|
ManageScreenContent(
|
||||||
uiState = State(
|
uiState = State(
|
||||||
isAuthorized = true,
|
isAuthorized = true,
|
||||||
|
userId = "preview",
|
||||||
userName = "User",
|
userName = "User",
|
||||||
userEmail = "user@example.com",
|
userEmail = "user@example.com",
|
||||||
devices = listOf(
|
devices = listOf(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ object ManageScreenContract {
|
|||||||
|
|
||||||
data class State(
|
data class State(
|
||||||
val isAuthorized: Boolean = false,
|
val isAuthorized: Boolean = false,
|
||||||
|
val userId: String = "",
|
||||||
val userName: String = "",
|
val userName: String = "",
|
||||||
val userEmail: String = "",
|
val userEmail: String = "",
|
||||||
val devicesLabel: 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.Event
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.State
|
import org.db3.airmq.features.manage.ManageScreenContract.State
|
||||||
import org.db3.airmq.sdk.auth.AuthService
|
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.data.remote.DeviceSubscriptionManager
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
import org.db3.airmq.sdk.device.domain.OnlineStatus
|
import org.db3.airmq.sdk.device.domain.OnlineStatus
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -30,6 +30,7 @@ class ManageViewModel @Inject constructor(
|
|||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
private val authService: AuthService,
|
private val authService: AuthService,
|
||||||
private val getMyDevicesUseCase: GetMyDevicesUseCase,
|
private val getMyDevicesUseCase: GetMyDevicesUseCase,
|
||||||
|
private val deviceRepository: DeviceRepository,
|
||||||
private val subscriptionManager: DeviceSubscriptionManager
|
private val subscriptionManager: DeviceSubscriptionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ class ManageViewModel @Inject constructor(
|
|||||||
if (session?.isAuthenticated == true) {
|
if (session?.isAuthenticated == true) {
|
||||||
val user = session
|
val user = session
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
if (state.isAuthorized) {
|
if (state.isAuthorized && state.userId == session.userId) {
|
||||||
state.copy(
|
state.copy(
|
||||||
devices = devices.map { device -> device.toDeviceItem(appContext) }
|
devices = devices.map { device -> device.toDeviceItem(appContext) }
|
||||||
)
|
)
|
||||||
@@ -75,8 +76,20 @@ class ManageViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val session = authService.getUser()
|
val session = authService.getUser()
|
||||||
if (session?.isAuthenticated == true) {
|
if (session?.isAuthenticated == true) {
|
||||||
subscriptionManager.start()
|
deviceRepository.ensureLocalDevicesMatchAccount(session.userId)
|
||||||
_uiState.value = authorizedState(session)
|
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 {
|
} else {
|
||||||
subscriptionManager.stop()
|
subscriptionManager.stop()
|
||||||
_uiState.value = anonymousState()
|
_uiState.value = anonymousState()
|
||||||
@@ -86,19 +99,12 @@ class ManageViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun anonymousState(): State = State(
|
private fun anonymousState(): State = State(
|
||||||
isAuthorized = false,
|
isAuthorized = false,
|
||||||
|
userId = "",
|
||||||
userName = appContext.getString(R.string.text_anonymous_user),
|
userName = appContext.getString(R.string.text_anonymous_user),
|
||||||
userEmail = appContext.getString(R.string.text_please_sign_in),
|
userEmail = appContext.getString(R.string.text_please_sign_in),
|
||||||
devicesLabel = appContext.getString(R.string.text_sign_in_small)
|
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 {
|
private fun org.db3.airmq.sdk.device.domain.Device.toDeviceItem(context: Context): DeviceItem {
|
||||||
val statusText = when (toOnlineStatus()) {
|
val statusText = when (toOnlineStatus()) {
|
||||||
OnlineStatus.Online -> context.getString(R.string.map_status_online)
|
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.RegisterMutation
|
||||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||||
import org.db3.airmq.sdk.auth.model.User
|
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.LocalAuthInput
|
||||||
import org.db3.airmq.sdk.type.RegisterInput
|
import org.db3.airmq.sdk.type.RegisterInput
|
||||||
|
|
||||||
@@ -17,7 +19,9 @@ class FirebaseAuthService @Inject constructor(
|
|||||||
private val firebaseSessionManager: FirebaseSessionManager,
|
private val firebaseSessionManager: FirebaseSessionManager,
|
||||||
private val apolloClient: ApolloClient,
|
private val apolloClient: ApolloClient,
|
||||||
private val apiTokenStore: ApiTokenStore,
|
private val apiTokenStore: ApiTokenStore,
|
||||||
private val localEmailAuthStore: LocalEmailAuthStore
|
private val localEmailAuthStore: LocalEmailAuthStore,
|
||||||
|
private val deviceRepository: DeviceRepository,
|
||||||
|
private val deviceSubscriptionManager: DeviceSubscriptionManager
|
||||||
) : AuthService {
|
) : AuthService {
|
||||||
|
|
||||||
override suspend fun getUser(): User? {
|
override suspend fun getUser(): User? {
|
||||||
@@ -77,9 +81,11 @@ class FirebaseAuthService @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun signOut(): Result<Unit> = runCatching {
|
override suspend fun signOut(): Result<Unit> = runCatching {
|
||||||
|
deviceSubscriptionManager.stop()
|
||||||
firebaseSessionManager.signOut()
|
firebaseSessionManager.signOut()
|
||||||
apiTokenStore.clearToken().getOrThrow()
|
apiTokenStore.clearToken().getOrThrow()
|
||||||
localEmailAuthStore.clearProfile().getOrThrow()
|
localEmailAuthStore.clearProfile().getOrThrow()
|
||||||
|
deviceRepository.clearLocalDeviceData()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +116,8 @@ class FirebaseAuthService @Inject constructor(
|
|||||||
?: error("Backend auth succeeded without API token.")
|
?: error("Backend auth succeeded without API token.")
|
||||||
val resolvedUserId = userId?.takeIf { it.isNotBlank() }
|
val resolvedUserId = userId?.takeIf { it.isNotBlank() }
|
||||||
?: error("Backend auth succeeded without user id.")
|
?: error("Backend auth succeeded without user id.")
|
||||||
|
deviceSubscriptionManager.stop()
|
||||||
|
deviceRepository.clearLocalDeviceData()
|
||||||
firebaseSessionManager.signOut()
|
firebaseSessionManager.signOut()
|
||||||
apiTokenStore.saveToken(resolvedToken).getOrThrow()
|
apiTokenStore.saveToken(resolvedToken).getOrThrow()
|
||||||
localEmailAuthStore.saveProfile(
|
localEmailAuthStore.saveProfile(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.db3.airmq.sdk.device.data
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
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.local.DeviceLocalDataSource
|
||||||
import org.db3.airmq.sdk.device.data.remote.DeviceRemoteDataSource
|
import org.db3.airmq.sdk.device.data.remote.DeviceRemoteDataSource
|
||||||
import org.db3.airmq.sdk.device.domain.Device
|
import org.db3.airmq.sdk.device.domain.Device
|
||||||
@@ -18,7 +19,8 @@ import javax.inject.Inject
|
|||||||
*/
|
*/
|
||||||
class DeviceRepositoryImpl @Inject constructor(
|
class DeviceRepositoryImpl @Inject constructor(
|
||||||
private val localDataSource: DeviceLocalDataSource,
|
private val localDataSource: DeviceLocalDataSource,
|
||||||
private val remoteDataSource: DeviceRemoteDataSource
|
private val remoteDataSource: DeviceRemoteDataSource,
|
||||||
|
private val deviceCacheSyncedUserStore: DeviceCacheSyncedUserStore
|
||||||
) : DeviceRepository {
|
) : DeviceRepository {
|
||||||
|
|
||||||
override fun observeDevices(): Flow<List<Device>> =
|
override fun observeDevices(): Flow<List<Device>> =
|
||||||
@@ -203,8 +205,20 @@ class DeviceRepositoryImpl @Inject constructor(
|
|||||||
localDataSource.markAllOnlineStatusStale()
|
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) }
|
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.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -47,6 +48,21 @@ interface DeviceDao {
|
|||||||
|
|
||||||
@Query("SELECT * FROM device WHERE id = :deviceId")
|
@Query("SELECT * FROM device WHERE id = :deviceId")
|
||||||
suspend fun getDevice(deviceId: String): DeviceEntity?
|
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
|
@Dao
|
||||||
@@ -69,4 +85,7 @@ interface PendingMutationDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM pending_mutation WHERE deviceId = :deviceId AND type = :type")
|
@Query("DELETE FROM pending_mutation WHERE deviceId = :deviceId AND type = :type")
|
||||||
suspend fun deleteByDeviceAndType(deviceId: String, type: String)
|
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?> =
|
fun observeDevice(deviceId: String): Flow<Device?> =
|
||||||
deviceDao.observeDevice(deviceId).map { it?.toDomain() }
|
deviceDao.observeDevice(deviceId).map { it?.toDomain() }
|
||||||
|
|
||||||
suspend fun upsertDevices(devices: List<Device>) {
|
suspend fun replaceDevicesFromSubscription(devices: List<Device>) {
|
||||||
val entities = devices.map { it.toEntity() }
|
deviceDao.replaceAllDevices(devices.map { it.toEntity() })
|
||||||
deviceDao.upsertDevices(entities)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun upsertDevice(device: Device) {
|
suspend fun clearAllLocalDeviceData() {
|
||||||
deviceDao.upsertDevice(device.toEntity())
|
deviceDao.deleteAllDevices()
|
||||||
|
pendingMutationDao.deleteAllPendingMutations()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateName(deviceId: String, newName: String) {
|
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
|
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.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
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.Device
|
||||||
import org.db3.airmq.sdk.device.domain.DeviceModel
|
import org.db3.airmq.sdk.device.domain.DeviceModel
|
||||||
import org.db3.airmq.sdk.device.domain.OnlineFreshness
|
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 {
|
interface DeviceRemoteDataSource {
|
||||||
|
|
||||||
/**
|
|
||||||
* Observe device list from GraphQL subscription.
|
|
||||||
* Phase 1: Mock flow. Phase 2: Apollo subscription.
|
|
||||||
*/
|
|
||||||
fun observeDevicesSubscription(): Flow<List<Device>>
|
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>
|
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>
|
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>
|
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>
|
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>
|
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>
|
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>
|
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>
|
suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class ApolloDeviceRemoteDataSource @Inject constructor(
|
||||||
* Mock implementation for Phase 1.
|
private val apolloClient: ApolloClient
|
||||||
*/
|
) : DeviceRemoteDataSource {
|
||||||
class MockDeviceRemoteDataSource @Inject constructor() : DeviceRemoteDataSource {
|
|
||||||
|
|
||||||
override fun observeDevicesSubscription(): Flow<List<Device>> = flow {
|
override fun observeDevicesSubscription(): Flow<List<Device>> = flow {
|
||||||
// Emit mock device list
|
while (currentCoroutineContext().isActive) {
|
||||||
val mockDevices = listOf(
|
emit(fetchDevices())
|
||||||
Device(
|
delay(POLL_INTERVAL_MS)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun renameDevice(deviceId: String, newName: String): Result<Unit> =
|
override suspend fun renameDevice(deviceId: String, newName: String): Result<Unit> = runCatching {
|
||||||
Result.success(Unit)
|
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> =
|
override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit> = runCatching {
|
||||||
Result.success(Unit)
|
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> =
|
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> =
|
override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit> = runCatching {
|
||||||
Result.success(Unit)
|
val locationId = resolveLocationId(deviceId)
|
||||||
|
mutateLocation(locationId) { current -> current.copy(Sensorcom = Optional.Present(enabled)) }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit> =
|
override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit> = runCatching {
|
||||||
Result.success(Unit)
|
val locationId = resolveLocationId(deviceId)
|
||||||
|
mutateLocation(locationId) { current -> current.copy(Narodmon = Optional.Present(enabled)) }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result<Unit> =
|
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> =
|
override suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result<Unit> = runCatching {
|
||||||
Result.success(Unit)
|
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> =
|
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> = runCatching {
|
||||||
Result.success(Unit)
|
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var isSubscribed = false
|
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() {
|
fun start(accountUserId: String) {
|
||||||
if (isSubscribed) return
|
if (isSubscribed && activeAccountUserId == accountUserId) return
|
||||||
|
stop()
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
|
activeAccountUserId = accountUserId
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
remoteDataSource.observeDevicesSubscription()
|
remoteDataSource.observeDevicesSubscription()
|
||||||
.catch {
|
.catch {
|
||||||
@@ -39,7 +42,7 @@ class DeviceSubscriptionManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEach { devices ->
|
.onEach { devices ->
|
||||||
repository.refreshFromSubscription(devices)
|
repository.refreshFromSubscription(accountUserId, devices)
|
||||||
}
|
}
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
}
|
}
|
||||||
@@ -49,6 +52,7 @@ class DeviceSubscriptionManager @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun stop() {
|
fun stop() {
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
|
activeAccountUserId = null
|
||||||
scope.cancel()
|
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.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_1_2
|
||||||
import org.db3.airmq.sdk.device.data.local.DEVICE_DB_MIGRATION_2_3
|
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.DeviceDao
|
||||||
import org.db3.airmq.sdk.device.data.local.DeviceDatabase
|
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.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.DeviceRemoteDataSource
|
||||||
import org.db3.airmq.sdk.device.data.remote.MockDeviceRemoteDataSource
|
|
||||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -52,5 +54,11 @@ abstract class DeviceBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@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
|
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.apollographql.apollo.api.ApolloResponse
|
||||||
import com.benasher44.uuid.uuid4
|
import com.benasher44.uuid.uuid4
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.db3.airmq.sdk.AuthGoogleAppMutation
|
import org.db3.airmq.sdk.AuthGoogleAppMutation
|
||||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||||
import org.db3.airmq.sdk.auth.model.User
|
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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
@@ -28,7 +32,7 @@ class FirebaseAuthServiceTest {
|
|||||||
val tokenStore = FakeApiTokenStore()
|
val tokenStore = FakeApiTokenStore()
|
||||||
val localEmailStore = FakeLocalEmailAuthStore()
|
val localEmailStore = FakeLocalEmailAuthStore()
|
||||||
mockAuthGoogleAppError(apolloClient, IllegalStateException("backend failed"))
|
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")
|
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||||
|
|
||||||
@@ -61,7 +65,9 @@ class FirebaseAuthServiceTest {
|
|||||||
uuid4()
|
uuid4()
|
||||||
).data(mutationData).build()
|
).data(mutationData).build()
|
||||||
coEvery { mockCall.execute() } returns apolloResponse
|
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")
|
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||||
|
|
||||||
@@ -70,6 +76,8 @@ class FirebaseAuthServiceTest {
|
|||||||
assertEquals("backend-id", localEmailStore.getProfile()?.userId)
|
assertEquals("backend-id", localEmailStore.getProfile()?.userId)
|
||||||
assertEquals("u@test.dev", localEmailStore.getProfile()?.email)
|
assertEquals("u@test.dev", localEmailStore.getProfile()?.email)
|
||||||
assertEquals(1, sessionManager.signOutCallCount)
|
assertEquals(1, sessionManager.signOutCallCount)
|
||||||
|
verify(exactly = 1) { subManager.stop() }
|
||||||
|
coVerify(exactly = 1) { deviceRepo.clearLocalDeviceData() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -81,7 +89,7 @@ class FirebaseAuthServiceTest {
|
|||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||||
val localEmailStore = FakeLocalEmailAuthStore()
|
val localEmailStore = FakeLocalEmailAuthStore()
|
||||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||||
|
|
||||||
assertTrue(service.isAuthenticated())
|
assertTrue(service.isAuthenticated())
|
||||||
|
|
||||||
@@ -97,7 +105,7 @@ class FirebaseAuthServiceTest {
|
|||||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||||
saveProfile("backend-id", "e@mail.test", "Name").getOrThrow()
|
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())
|
assertTrue(service.isAuthenticated())
|
||||||
}
|
}
|
||||||
@@ -110,7 +118,7 @@ class FirebaseAuthServiceTest {
|
|||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = null)
|
val tokenStore = FakeApiTokenStore(storedToken = null)
|
||||||
val localEmailStore = FakeLocalEmailAuthStore()
|
val localEmailStore = FakeLocalEmailAuthStore()
|
||||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||||
|
|
||||||
assertNull(service.getUser())
|
assertNull(service.getUser())
|
||||||
}
|
}
|
||||||
@@ -123,7 +131,7 @@ class FirebaseAuthServiceTest {
|
|||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||||
val localEmailStore = FakeLocalEmailAuthStore()
|
val localEmailStore = FakeLocalEmailAuthStore()
|
||||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
val service = firebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||||
|
|
||||||
val user = service.getUser()
|
val user = service.getUser()
|
||||||
assertNotNull(user)
|
assertNotNull(user)
|
||||||
@@ -139,7 +147,7 @@ class FirebaseAuthServiceTest {
|
|||||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||||
saveProfile("bid", "local@test.dev", "Local User").getOrThrow()
|
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()
|
val user = service.getUser()
|
||||||
assertNotNull(user)
|
assertNotNull(user)
|
||||||
@@ -157,11 +165,15 @@ class FirebaseAuthServiceTest {
|
|||||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||||
saveProfile("id", "a@b.c", null).getOrThrow()
|
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)
|
assertTrue(service.signOut().isSuccess)
|
||||||
assertNull(tokenStore.storedToken)
|
assertNull(tokenStore.storedToken)
|
||||||
assertNull(localEmailStore.getProfile())
|
assertNull(localEmailStore.getProfile())
|
||||||
|
verify(exactly = 1) { subManager.stop() }
|
||||||
|
coVerify(exactly = 1) { deviceRepo.clearLocalDeviceData() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mockAuthGoogleAppError(apolloClient: ApolloClient, error: Throwable) {
|
private fun mockAuthGoogleAppError(apolloClient: ApolloClient, error: Throwable) {
|
||||||
@@ -169,6 +181,22 @@ class FirebaseAuthServiceTest {
|
|||||||
every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
|
every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
|
||||||
coEvery { mockCall.execute() } throws error
|
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(
|
private class FakeFirebaseSessionManager(
|
||||||
|
|||||||
Reference in New Issue
Block a user