Compare commits

...

3 Commits

Author SHA1 Message Date
bf83336bbc WIP: MapScreen.kt changes 2026-04-19 10:00:06 +02:00
89ce2e1afa 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
2026-04-07 18:04:42 +02:00
0a79ee5e04 Fix city selection vs dashboard; Map/Manage updates; workspace and API logging
Made-with: Cursor
2026-04-06 23:49:35 +02:00
25 changed files with 608 additions and 181 deletions

View File

@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../airmq-android"
},
{
"path": ".."
}
],
"settings": {}
}

View File

@@ -79,7 +79,12 @@ class CityViewModel @Inject constructor(
private fun selectCity(city: org.db3.airmq.sdk.city.domain.City) {
viewModelScope.launch(Dispatchers.IO) {
cityService.setSelectedCity(city.id)
_actions.tryEmit(CityScreenContract.Action.NavigateBack)
.onSuccess { _actions.tryEmit(CityScreenContract.Action.NavigateBack) }
.onFailure {
_actions.tryEmit(
CityScreenContract.Action.ShowToast(appContext.getString(R.string.toast_error))
)
}
}
}
@@ -93,8 +98,8 @@ class CityViewModel @Inject constructor(
private fun enableDetectAutomaticallyWithLocation(location: Location?) {
viewModelScope.launch(Dispatchers.IO) {
if (location != null) {
cityService.refreshCityFromLocation(location)
cityService.setDetectAutomatically(true)
cityService.refreshCityFromLocation(location)
val selectedCity = cityService.getSelectedCity()
_uiState.value = _uiState.value.copy(
detectAutomatically = true,

View File

@@ -411,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

@@ -2,6 +2,7 @@ package org.db3.airmq.features.map
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Point
import android.graphics.drawable.BitmapDrawable
import android.os.Handler
import android.os.Looper
@@ -22,8 +23,14 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -63,6 +70,8 @@ import org.osmdroid.views.overlay.Marker
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
@@ -98,6 +107,29 @@ fun MapScreen(
)
}
/**
* Bottom-sheet height fraction for map camera padding (0 = none, 0.5 = half screen).
* Uses [SheetState] so the map reacts as soon as open/close *animations* start, not only when
* values settle: closing uses target Hidden while still expanded; opening must not keep 0 inset
* while both values are still Hidden (that delayed the camera until the sheet finished opening).
*/
@OptIn(ExperimentalMaterial3Api::class)
private fun mapSheetFractionForDevicePanel(
devicePanelPresent: Boolean,
sheetState: SheetState
): Float {
if (!devicePanelPresent) return 0f
return when {
sheetState.currentValue == SheetValue.Expanded &&
sheetState.targetValue == SheetValue.Expanded -> 0.5f
sheetState.targetValue == SheetValue.Hidden &&
sheetState.currentValue == SheetValue.Expanded -> 0f
sheetState.targetValue == SheetValue.Expanded &&
sheetState.currentValue == SheetValue.Hidden -> 0.5f
else -> 0.5f
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MapScreenContent(
@@ -106,10 +138,22 @@ private fun MapScreenContent(
showMap: Boolean
) {
val context = LocalContext.current
val modalBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val centerOnMarker = uiState.selectedMarkerId?.let { id ->
uiState.items.find { it.id == id }
}
val sheetHeightFraction = if (uiState.devicePanelState != null) 0.5f else 0f
// Map padding tracks sheet motion: interpolate with SheetState.progress during open/close so the
// camera offset stays in sync with the panel animation (not only after VM/sheet settle).
val mapSheetHeightFraction = mapSheetFractionForDevicePanel(
devicePanelPresent = uiState.devicePanelState != null,
sheetState = modalBottomSheetState
)
LaunchedEffect(uiState.devicePanelState?.id) {
if (uiState.devicePanelState != null) {
modalBottomSheetState.show()
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (showMap) {
@@ -117,7 +161,7 @@ private fun MapScreenContent(
items = uiState.items,
onMarkerClick = { onEvent(Event.MarkerClicked(it)) },
centerOnMarker = centerOnMarker,
sheetHeightFraction = sheetHeightFraction,
sheetHeightFraction = mapSheetHeightFraction,
clusterEnabled = true,
modifier = Modifier.fillMaxSize()
)
@@ -165,10 +209,9 @@ private fun MapScreenContent(
}
uiState.devicePanelState?.let { panelData ->
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = { onEvent(Event.DevicePanelClosed) },
sheetState = sheetState
sheetState = modalBottomSheetState
) {
MapDevicePanelContent(
data = panelData,
@@ -200,6 +243,22 @@ private const val MapClusterDebounceMs = 150L
private const val MapClusterDistanceDp = 48f
private const val MapClusterZoomPaddingPx = 64
/** Extra time after [mapAnimationSpeedMs] so osmdroid ValueAnimator + layout can finish before we read Projection. */
private const val MapCameraSettleExtraMs = 48L
/**
* Waits until [MapView.isAnimating] is false (zoom/pan animation) or [maxWaitMs], then one more frame.
*/
private suspend fun MapView.awaitMapAnimationSettled(maxWaitMs: Long = 600L) {
val step = 16L
var waited = 0L
while (isAnimating() && waited < maxWaitMs) {
delay(step)
waited += step
}
delay(MapCameraSettleExtraMs)
}
@Composable
private fun AirMQMap(
items: List<MapMarker>,
@@ -216,6 +275,9 @@ private fun AirMQMap(
val latestOnMarkerClick = rememberUpdatedState(onMarkerClick)
val latestClusterEnabled = rememberUpdatedState(clusterEnabled)
val latestCenterOnMarker = rememberUpdatedState(centerOnMarker)
val latestSheetHeightFraction = rememberUpdatedState(sheetHeightFraction)
/** Tracks sheet inset so we can run a one-shot recenter when the panel fully dismisses (fraction 0). */
var previousSheetFraction by remember { mutableFloatStateOf(0f) }
val initialCameraDone = remember { AtomicBoolean(false) }
@@ -279,43 +341,79 @@ private fun AirMQMap(
}
val mapAnimationSpeedMs = 200L
LaunchedEffect(centerOnMarker, sheetHeightFraction) {
centerOnMarker?.let { marker ->
val markerGeo = GeoPoint(marker.latitude, marker.longitude)
val zoomLevel = 15.5
if (sheetHeightFraction > 0f) {
val markerZoomLevel = 15.5
// Zoom/pan starts as soon as the marker is selected (same time as the bottom sheet), not after it opens.
LaunchedEffect(centerOnMarker?.id) {
val marker = centerOnMarker ?: return@LaunchedEffect
val markerGeo = GeoPoint(marker.latitude, marker.longitude)
mapView.controller.animateTo(markerGeo, markerZoomLevel, mapAnimationSpeedMs)
coroutineScope {
launch {
var waited = 0
while (latestSheetHeightFraction.value <= 0f && waited < 500) {
delay(16)
waited += 16
}
val frac = latestSheetHeightFraction.value
if (frac <= 0f) return@launch
delay(mapAnimationSpeedMs)
mapView.awaitMapAnimationSettled()
mapView.post {
val height = mapView.height
val width = mapView.width
if (height > 0 && width > 0) {
val sheetHeightPx = (height * sheetHeightFraction).toInt()
val offsetCenterY = height / 2 + sheetHeightPx / 2
val projection = mapView.projection
val offsetGeo = projection.fromPixels(width / 2, offsetCenterY)
mapView.controller.animateTo(offsetGeo, zoomLevel, mapAnimationSpeedMs)
}
if (height <= 0) return@post
val sheetHeightPx = (height * frac).toInt()
val targetY = (height - sheetHeightPx) / 2
val projection = mapView.projection
val p = Point()
projection.toPixels(markerGeo, p)
mapView.scrollBy(0, p.y - targetY)
}
} else {
mapView.controller.animateTo(markerGeo, zoomLevel, mapAnimationSpeedMs)
}
}
}
// When the device panel dismisses, recenter on the marker for the full map (no sheet inset).
LaunchedEffect(sheetHeightFraction, centerOnMarker?.id) {
val marker = centerOnMarker
if (marker == null) {
previousSheetFraction = sheetHeightFraction
return@LaunchedEffect
}
val wasSheet = previousSheetFraction > 0f
val isSheet = sheetHeightFraction > 0f
if (wasSheet && !isSheet) {
mapView.controller.animateTo(
GeoPoint(marker.latitude, marker.longitude),
markerZoomLevel,
mapAnimationSpeedMs
)
}
previousSheetFraction = sheetHeightFraction
}
// Overlay rebuild must be keyed off composable inputs: AndroidView's `update` runs without
// recording snapshot reads when values are only read inside View.post {}, so map items can
// load without a rebuild until something else (e.g. pan → debounced rebuild) retriggers it.
LaunchedEffect(items, centerOnMarker, clusterEnabled) {
val snapshotItems = items
val snapshotCenter = centerOnMarker
val snapshotCluster = clusterEnabled
mapView.post {
rebuildAirMqMapOverlays(
map = mapView,
items = snapshotItems,
onMarkerClick = latestOnMarkerClick.value,
clusterEnabled = snapshotCluster,
centerOnMarker = snapshotCenter,
initialCameraDone = initialCameraDone
)
}
}
AndroidView(
modifier = modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.post {
rebuildAirMqMapOverlays(
map = map,
items = latestItems.value,
onMarkerClick = latestOnMarkerClick.value,
clusterEnabled = latestClusterEnabled.value,
centerOnMarker = latestCenterOnMarker.value,
initialCameraDone = initialCameraDone
)
}
}
factory = { mapView }
)
}

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

@@ -24,6 +24,7 @@ interface CityService {
/**
* If [DashboardCityContext.cityId] is missing, tries to resolve it from the local city DB
* using the stored English name and updates preferences.
* Does not notify [observeDashboardCityContext] (avoids re-entrant emissions during dashboard load).
*/
suspend fun refreshDashboardCityIdentity()
@@ -80,9 +81,9 @@ interface CityService {
suspend fun setDetectAutomatically(enabled: Boolean)
/**
* Refreshes the selected city from location. Used when detect automatically is on.
* Refreshes the selected city from location when [getDetectAutomatically] is true.
* Resolves closest city from the cities DB and updates stored city.
* No-op if location is null or no matching city found.
* No-op if auto-detect is off, location is null, or no matching city found.
*/
suspend fun refreshCityFromLocation(location: Location?)

View File

@@ -101,6 +101,7 @@ class CityServiceImpl @Inject constructor(
}
override suspend fun refreshCityFromLocation(location: Location?) {
if (!getDetectAutomatically()) return
if (location == null) return
val cities = ensureCitiesInDb()
val resolvedCity = findClosestCity(cities, location.latitude, location.longitude)
@@ -121,7 +122,6 @@ class CityServiceImpl @Inject constructor(
val city = cityLocalDataSource.getAllCities()
.find { it.nameEn.equals(nameEn, ignoreCase = true) } ?: return
prefs.edit().putString(KEY_DASHBOARD_CITY_ID, city.id).apply()
pushContextUpdate()
}
override suspend fun getResolvedDashboardCityContext(): DashboardCityContext {

View File

@@ -114,7 +114,12 @@ class DashboardMetricsRepositoryImpl @Inject constructor(
}
private fun mapLastRow(row: CityAverageLastQuery.CityAverageLast): SensorSampleRow? {
val t = GraphqlDateTimeParser.parseToEpochMillis(row.time) ?: return null
val parsedTime = GraphqlDateTimeParser.parseToEpochMillis(row.time)
val hasAnyReading = row.Temp != null || row.Hum != null || row.Press != null ||
row.PMS1 != null || row.PMS25 != null || row.PMS10 != null ||
row.radRg != null || row.PPM != null || row.IKAV != null ||
row.CO2 != null || row.VOC != null || row.AQI != null
val t = parsedTime ?: if (hasAnyReading) System.currentTimeMillis() else return null
return SensorSampleRow(
epochMillis = t,
temp = row.Temp?.toFloat(),

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 setVisibility(deviceId: String, isPublic: Boolean): Result<Unit> = runCatching {
val location = fetchLocationByDeviceId(deviceId)
?: error("Device not found: $deviceId")
val locationId = location._id
val locationName = location.name
val response = apolloClient
.mutation(
AddLocationMutation(
input = Optional.Present(
LocationInput(
_id = Optional.Present(locationId),
isPublic = Optional.Present(isPublic),
name = Optional.Present(locationName)
)
)
)
)
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
response.data?.addLocation?._id ?: error("addLocation update returned no location id.")
}
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> =
Result.success(Unit)
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> = runCatching {
val response = apolloClient
.mutation(
DeviceActionMutation(
input = Optional.Present(
SendAction(
deviceId = deviceId,
fwUpgrade = Optional.Present(true)
)
)
)
)
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
val success = response.data?.deviceAction?.success ?: false
if (!success) error("Firmware update request was rejected by API.")
}
private suspend fun fetchDevices(): List<Device> {
val response = apolloClient.query(MyLocationsDevicesQuery()).execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
return response.data?.me?.locations.orEmpty().mapNotNull { it?.toDomain() }
}
private suspend fun fetchLocationByDeviceId(deviceId: String): MyLocationsDevicesQuery.Location? =
fetchLocationsRaw().firstOrNull { location ->
location.device?._id == deviceId || location.deviceId == deviceId
}
private suspend fun fetchLocationsRaw(): List<MyLocationsDevicesQuery.Location> {
val response = apolloClient.query(MyLocationsDevicesQuery()).execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
return response.data?.me?.locations.orEmpty().mapNotNull { it }
}
private suspend fun resolveLocationId(deviceId: String): String {
val location = fetchLocationByDeviceId(deviceId)
?: error("Device not found: $deviceId")
return location._id
}
private suspend fun mutateLocation(
locationId: String,
update: (ChgLocation) -> ChgLocation
) {
val input = update(ChgLocation(_id = locationId))
val response = apolloClient
.mutation(ChangeLocationMutation(input = Optional.Present(input)))
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
response.data?.changeLocation?._id ?: error("changeLocation returned no location id.")
}
private fun MyLocationsDevicesQuery.Location.toDomain(): Device? {
val id = device?._id ?: deviceId ?: return null
val modelName = device?.model?.name
val isOnlineResolved = device?.status?.isOnline ?: isOnline ?: false
return Device(
id = id,
name = name.takeIf { it.isNotBlank() } ?: id,
model = DeviceModel.fromString(modelName),
firmwareVersion = device?.hardware?.firmwareVersion ?: "",
deviceAddress = device?.status?.local_ip ?: "",
configVersion = device?.hardware?.configVersion ?: "",
isNarodmonOn = device?.Narodmon ?: Narodmon ?: false,
isLuftdataOn = false,
locationId = _id,
latitude = latitude,
longitude = longitude,
isPublic = isPublic ?: false,
city = city,
dataSharingEnabled = device?.Sensorcom ?: Sensorcom ?: false,
isOnline = isOnlineResolved,
onlineFreshness = OnlineFreshness.Fresh,
ownerId = null
)
}
private companion object {
const val POLL_INTERVAL_MS = 30_000L
}
}

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(