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