Skip to main content

DataStore Architecture

The app uses a set of in-memory and persistent DataStores as the single source of truth for all client-side state. Each DataStore exposes Kotlin StateFlow / SharedFlow properties so ViewModels receive updates reactively from both HTTP API responses and real-time WebSocket events.

```mermaid
flowchart TD
HTTP["HTTP API"] --> Repository --> DataStore
WebSocket --> RealTimeDataBridge --> DataStore
DataStore -- "StateFlow / SharedFlow" --> ViewModel --> UI

Package layout​

FilePackage
EncryptedTokenDataStore.ktcore.network
AppPreferencesDataStore.ktcore.data
DeviceDataStore.ktcore.data
GeofenceDataStore.ktcore.data
NotificationDataStore.ktcore.data
InviteDataStore.ktcore.data
SharingDataStore.ktcore.data
RealTimeDataBridge.ktcore.data
WidgetDataStore.ktwidget

1. EncryptedTokenDataStore​

Location: core/network/EncryptedTokenDataStore.kt

Persists authentication tokens and user identity using Jetpack DataStore Preferences with Google Tink AES-256-GCM encryption on top.

Constructor​

@Singleton
class EncryptedTokenDataStore @Inject constructor(private val context: Context)

Backing store​

private val Context.tokenDataStore: DataStore<Preferences>
by preferencesDataStore(name = "visla_tokens")

Encryption​

A Tink Aead primitive is initialised lazily. The keyset is stored in SharedPreferences (visla_token_prefs) and protected by an Android Keystore master key (visla_token_master_key).

private val aead: Aead by lazy {
AeadConfig.register()
try { buildAead() }
catch (e: java.security.InvalidKeyException) { resetKeystoreAndPrefs(); buildAead() }
catch (e: java.security.GeneralSecurityException) { resetKeystoreAndPrefs(); buildAead() }
}

buildAead() creates an AndroidKeysetManager with AES256_GCM template and master key URI android-keystore://visla_token_master_key.

If the keystore key is corrupted, resetKeystoreAndPrefs() deletes the Android Keystore entry, clears the keyset SharedPreferences, and clears the DataStore.

Stored fields​

KeyPreferences typeEncrypted
access_tokenstringPreferencesKeyβœ…
refresh_tokenstringPreferencesKeyβœ…
user_idintPreferencesKey❌
user_emailstringPreferencesKeyβœ…
user_namestringPreferencesKeyβœ…

Async access (Flow)​

PropertyType
accessTokenFlow<String?>
refreshTokenFlow<String?>
userIdFlow<Int?>
userEmailFlow<String?>
userNameFlow<String?>

Each Flow reads from tokenDataStore.data and decrypts inline. userId filters out the sentinel value -1.

Sync access (blocking)​

MethodReturn
getAccessTokenSync()String?
getRefreshTokenSync()String?
getUserIdSync()Int?
getUserEmailSync()String?
getUserNameSync()String?

All implemented via runBlocking { flow.first() }. Used by interceptors / authenticators that run outside a coroutine context.

Mutators​

MethodSignature
setAccessTokensuspend fun setAccessToken(token: String?)
setRefreshTokensuspend fun setRefreshToken(token: String?)
setUserIdsuspend fun setUserId(id: Int?)
setUserEmailsuspend fun setUserEmail(email: String?)
setUserNamesuspend fun setUserName(name: String?)
saveAllsuspend fun saveAll(accessToken: String?, refreshToken: String?, userId: Int?, email: String?, name: String?)
clearsuspend fun clear()

saveAll performs a single edit transaction for all five fields. Individual setters store encrypted values or remove the key when null.

Threading​

No Mutex β€” thread-safety is provided by Jetpack DataStore's internal actor.


2. AppPreferencesDataStore​

Location: core/data/AppPreferencesDataStore.kt

Persists user-facing app settings (theme, language) via Jetpack DataStore Preferences.

Constructor​

@Singleton
class AppPreferencesDataStore @Inject constructor(private val context: Context)

Backing store​

private val Context.appPreferencesDataStore: DataStore<Preferences>
by preferencesDataStore(name = "visla_app_preferences")

Stored fields​

KeyTypeDomain enum
theme_modestringPreferencesKeyThemeMode (SYSTEM, DARK, LIGHT)
languagestringPreferencesKeyAppLanguage (ENGLISH("en"), ITALIAN("it"))

Supporting enums​

ThemeMode β€” stored as "system" / "dark" / "light". Parsed via ThemeMode.fromString(value), defaults to SYSTEM.

AppLanguage β€” stored by ISO code. AppLanguage.fromCode(code) falls back to detectDeviceLanguage() which reads Locale.getDefault().language and defaults to ITALIAN.

Properties & methods​

MemberType / Signature
themeModeFlow<ThemeMode>
getThemeModeSync()fun getThemeModeSync(): ThemeMode
setThemeModesuspend fun setThemeMode(mode: ThemeMode)
languageFlow<AppLanguage>
getLanguageSync()fun getLanguageSync(): AppLanguage
setLanguagesuspend fun setLanguage(language: AppLanguage)
clearsuspend fun clear()

Threading​

No Mutex β€” thread-safety provided by Jetpack DataStore.


3. DeviceDataStore​

Location: core/data/DeviceDataStore.kt

In-memory single source of truth for devices and their latest positions. Merges data from HTTP API (initial load) and WebSocket (real-time updates).

Constructor​

@Singleton
class DeviceDataStore @Inject constructor()

Threading​

Uses kotlinx.coroutines.sync.Mutex β€” all mutations are wrapped in mutex.withLock { }.

State flows​

PropertyTypeDescription
devicesWithPositionsStateFlow<List<DeviceWithPosition>>Primary device list
isInitializedStateFlow<Boolean>true after first API load

Event streams​

PropertyTypeBuffer
positionUpdatesSharedFlow<Position>extraBufferCapacity = 64
deviceStatusUpdatesSharedFlow<Pair<Int, DeviceStatus>>extraBufferCapacity = 64

Initialization​

MethodSignatureNotes
initializesuspend fun initialize(devices: List<DeviceWithPosition>)First load; sets isInitialized = true
setDevicessuspend fun setDevices(devices: List<DeviceWithPosition>)Replaces list (pull-to-refresh, re-login)

Real-time update methods​

MethodSignatureNotes
updatePositionsuspend fun updatePosition(position: Position)Skips if latitude/longitude are null; emits to positionUpdates via tryEmit
updateDeviceStatussuspend fun updateDeviceStatus(deviceId: Int, status: String)Converts via DeviceStatus.fromString; sets lastUpdate = Instant.now(); emits to deviceStatusUpdates
updateDevicesuspend fun updateDevice(device: Device)Replaces device details (name, icon, color, etc.)
addDevicesuspend fun addDevice(deviceWithPosition: DeviceWithPosition)Upserts β€” updates if ID already exists
removeDevicesuspend fun removeDevice(deviceId: Int)Filters out by ID

Query methods (non-suspending)​

MethodSignature
getDevicefun getDevice(deviceId: Int): Device?
getDeviceWithPositionfun getDeviceWithPosition(deviceId: Int): DeviceWithPosition?
getPositionfun getPosition(deviceId: Int): Position?

State management​

MethodSignatureNotes
clearsuspend fun clear()Empties list, resets isInitialized
invalidatefun invalidate()Sets isInitialized = false without clearing data

4. GeofenceDataStore​

Location: core/data/GeofenceDataStore.kt

In-memory single source of truth for geofences. Supports per-device lazy loading.

Constructor​

@Singleton
class GeofenceDataStore @Inject constructor()

Threading​

Uses Mutex β€” all mutations wrapped in mutex.withLock { }.

State flows​

PropertyType
geofencesStateFlow<List<Geofence>>
isInitializedStateFlow<Boolean>

Internal state​

loadedDeviceIds: MutableSet<Int> β€” tracks which devices have had their geofences fetched.

Initialization​

MethodSignatureNotes
setGeofencessuspend fun setGeofences(geofences: List<Geofence>)Full replacement
setGeofencesForDevicesuspend fun setGeofencesForDevice(deviceId: Int, geofences: List<Geofence>)Merges with existing; filters out geofences whose deviceIds contain deviceId then appends
isDeviceLoadedfun isDeviceLoaded(deviceId: Int): BooleanChecks loadedDeviceIds

CRUD​

MethodSignatureNotes
addGeofencesuspend fun addGeofence(geofence: Geofence)Upserts β€” updates if id already exists
updateGeofencesuspend fun updateGeofence(geofence: Geofence)Maps by id
removeGeofencesuspend fun removeGeofence(id: Int)Filters by id

Query methods​

MethodSignature
getGeofencesByDevicefun getGeofencesByDevice(deviceId: Int): List<Geofence>
getGeofencefun getGeofence(id: Int): Geofence?

State management​

MethodSignatureNotes
clearsuspend fun clear()Empties list, resets init flag, clears loadedDeviceIds
invalidateDevicefun invalidateDevice(deviceId: Int)Removes from loadedDeviceIds
invalidatefun invalidate()Resets init flag and clears loadedDeviceIds

5. NotificationDataStore​

Location: core/data/NotificationDataStore.kt

In-memory store for notification history. Bridges HTTP API (initial/paginated load), FCM push (foreground), and future WebSocket events.

Constructor​

@Singleton
class NotificationDataStore @Inject constructor()

Constants​

companion object {
const val DEFAULT_PAGE_SIZE = 10
}

Threading​

Uses Mutex.

State flows​

PropertyTypeDescription
notificationsStateFlow<List<NotificationHistoryItem>>Newest-first
isLoadingStateFlow<Boolean>Loading indicator
hasMoreStateFlow<Boolean>Pagination flag
unreadCountStateFlow<Int>Count of items where readAt == null

Event stream​

PropertyTypeBuffer
newNotificationEventSharedFlow<NotificationHistoryItem>extraBufferCapacity = 16

Initialization​

MethodSignature
setNotificationssuspend fun setNotifications(notifications: List<NotificationHistoryItem>, hasMore: Boolean)
appendNotificationssuspend fun appendNotifications(notifications: List<NotificationHistoryItem>, hasMore: Boolean)

Real-time updates​

MethodSignatureNotes
addNotificationsuspend fun addNotification(notification: NotificationHistoryItem, emitEvent: Boolean = true)Deduplicates by id; prepends to list; emits newNotificationEvent
addFromPushsuspend fun addFromPush(userId: Int, title: String, body: String, deviceId: Int?, eventType: String?, channel: String = "push")Creates a NotificationHistoryItem with a negative temp ID and delegates to addNotification

Mark-as-read​

MethodSignature
markAsReadsuspend fun markAsRead(notificationId: Int)
markAllAsReadsuspend fun markAllAsRead()
removeNotificationsuspend fun removeNotification(notificationId: Int)
clearAllsuspend fun clearAll()

Loading state​

MethodSignature
setLoadingfun setLoading(loading: Boolean)

Query methods​

MethodSignature
getByTypefun getByType(eventType: String): List<NotificationHistoryItem>
getByDevicefun getByDevice(deviceId: Int): List<NotificationHistoryItem>
getUnreadfun getUnread(): List<NotificationHistoryItem>

State management​

MethodSignatureNotes
clearsuspend fun clear()Resets all state including isLoading
invalidatefun invalidate()Sets hasMore = true

6. InviteDataStore​

Location: core/data/InviteDataStore.kt

In-memory store for pending sharing invites received by the current user.

Constructor​

@Singleton
class InviteDataStore @Inject constructor()

Threading​

Uses Mutex.

State flows​

PropertyType
invitesStateFlow<List<UserInvite>>
isInitializedStateFlow<Boolean>

Event stream​

PropertyTypeBuffer
newInviteEventSharedFlow<UserInvite>extraBufferCapacity = 16

Methods​

MethodSignatureNotes
setInvitessuspend fun setInvites(invites: List<UserInvite>)Sorts by expiresAt
addInvitesuspend fun addInvite(event: ShareInviteReceivedEvent)Deduplicates by token; constructs UserInvite from event; emits newInviteEvent
removeInvitesuspend fun removeInvite(token: String)Filters by token
clearsuspend fun clear()Empties list, resets init flag
invalidatefun invalidate()Sets isInitialized = false
getInviteCountfun getInviteCount(): IntReturns current list size

7. SharingDataStore​

Location: core/data/SharingDataStore.kt

In-memory store for per-device sharing state (active shares and pending invites).

Constructor​

@Singleton
class SharingDataStore @Inject constructor()

Threading​

Uses Mutex.

State flows​

PropertyType
deviceSharesStateFlow<Map<Int, DeviceSharesInfo>>

Event stream​

PropertyTypeBuffer
shareAcceptedEventSharedFlow<ShareAcceptedEvent>extraBufferCapacity = 16

Methods​

MethodSignatureNotes
setDeviceSharessuspend fun setDeviceShares(deviceId: Int, info: DeviceSharesInfo)Adds/replaces entry in map
getDeviceSharesfun getDeviceShares(deviceId: Int): DeviceSharesInfo?Synchronous lookup
onShareAcceptedsuspend fun onShareAccepted(event: ShareAcceptedEvent)Converts pending invite β†’ active DeviceShare; removes matching pending by email (case-insensitive); upserts share by user.id; emits shareAcceptedEvent
addPendingInvitesuspend fun addPendingInvite(deviceId: Int, invite: PendingInvite)Appends to existing DeviceSharesInfo.pendingInvites
removeSharesuspend fun removeShare(deviceId: Int, userId: Int)Filters shares by user.id
clearDevicesuspend fun clearDevice(deviceId: Int)Removes device key from map
clearsuspend fun clear()Empties map
isDeviceLoadedfun isDeviceLoaded(deviceId: Int): BooleanChecks containsKey

8. WidgetDataStore​

Location: widget/WidgetDataStore.kt

Persists device snapshots and auth tokens for home-screen widget access. Uses plain SharedPreferences (not Jetpack DataStore) because widgets run in a separate process and need synchronous reads.

Declaration​

object WidgetDataStore

Singleton object β€” no DI, no coroutines.

Backing store​

private const val PREFS_NAME = "visla_widget_prefs"

Retrieved via context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).

Stored keys​

Key constantSharedPreferences keyType
KEY_DEVICESwidget_devicesJSON string (List<WidgetDevice>)
KEY_ACCESS_TOKENwidget_access_tokenString?
KEY_REFRESH_TOKENwidget_refresh_tokenString?
KEY_HAS_EVER_SYNCEDwidget_has_ever_syncedBoolean

WidgetDevice model​

data class WidgetDevice(
val id: Int,
val name: String,
val icon: String?,
val isOnline: Boolean,
val isSuspended: Boolean,
val isMuted: Boolean
)

Serialised/deserialised via Gson.

Methods​

MethodSignatureNotes
hasEverSyncedfun hasEverSynced(context: Context): Boolean
saveDevicesfun saveDevices(context: Context, devices: List<WidgetDevice>)Sets KEY_HAS_EVER_SYNCED = true
loadDevicesfun loadDevices(context: Context): List<WidgetDevice>Returns emptyList() on parse failure
saveAccessTokenfun saveAccessToken(context: Context, token: String?)
getAccessTokenfun getAccessToken(context: Context): String?
saveRefreshTokenfun saveRefreshToken(context: Context, token: String?)
getRefreshTokenfun getRefreshToken(context: Context): String?
updateDeviceMuteStatusfun updateDeviceMuteStatus(context: Context, deviceId: Int, isMuted: Boolean)Uses edit(commit = true) for synchronous write
clearfun clear(context: Context)

Threading​

All operations are synchronous. updateDeviceMuteStatus uses commit = true (synchronous write) to guarantee data is persisted before the widget reads it.


9. RealTimeDataBridge​

Location: core/data/RealTimeDataBridge.kt

Orchestrates the flow of WebSocket events from RealTimeRepository into the appropriate DataStores. Acts as the wiring layer β€” it subscribes to every real-time event flow and routes updates to the correct store.

Constructor​

@Singleton
class RealTimeDataBridge @Inject constructor(
private val realTimeRepository: RealTimeRepository,
private val deviceDataStore: DeviceDataStore,
private val geofenceDataStore: GeofenceDataStore,
private val notificationDataStore: NotificationDataStore,
private val inviteDataStore: InviteDataStore,
private val sharingDataStore: SharingDataStore,
private val lazyDeviceRepository: Lazy<DeviceRepository> // Dagger Lazy to break circular dep
)

Internal state​

FieldTypeDescription
scopeCoroutineScope?SupervisorJob() + Dispatchers.Default; created in start()
isRunningBooleanGuard against double-start

Lifecycle​

MethodSignatureNotes
startfun start()Connects WebSocket, launches all collectors
stopfun stop()Disconnects WebSocket, cancels scope
reconnectfun reconnect()Disconnects then reconnects (e.g. after claiming a device)
isActivefun isActive(): BooleanReturns isRunning

Event routing​

start() calls private setup methods that each launch a coroutine to collect from a RealTimeRepository flow:

Setup methodSource flowTarget DataStoreTarget method
setupPositionUpdatespositionUpdatesDeviceDataStoreupdatePosition(position)
setupDeviceStatusUpdatesdeviceStatusUpdatesDeviceDataStoreupdateDeviceStatus(deviceId, status)
setupGeofenceEventsgeofenceCreatedGeofenceDataStoreaddGeofence(geofence)
geofenceUpdatedGeofenceDataStoreupdateGeofence(geofence)
geofenceDeletedGeofenceDataStoreremoveGeofence(geofenceId)
setupNotificationEventsnotificationReceivedNotificationDataStoreaddNotification(notification, emitEvent = true)
setupDeviceSettingsEventsdeviceSettingsChangedDeviceDataStoreupdateDevice(device) β€” fetches fresh device via lazyDeviceRepository.get().getDevice(deviceId)
setupShareEventsshareRevokedDeviceDataStoreremoveDevice(deviceId)
GeofenceDataStoreinvalidateDevice(deviceId)
shareInviteReceivedInviteDataStoreaddInvite(event)
shareAcceptedSharingDataStoreonShareAccepted(event)
setupConnectionStateMonitoringisConnected(logging only)β€”

Pending-updates queue​

Position and device-status events that arrive before DeviceDataStore.isInitialized becomes true are buffered in CopyOnWriteArrayList queues. setupPendingUpdatesProcessor suspends on deviceDataStore.isInitialized.first { it } then drains both queues into the DataStore.


Common patterns​

Initialization guard​

Most in-memory DataStores expose isInitialized: StateFlow<Boolean>. Repositories set this to true after the first successful API fetch. RealTimeDataBridge waits for it before forwarding WebSocket events.

Mutex for in-memory stores​

DeviceDataStore, GeofenceDataStore, NotificationDataStore, InviteDataStore, and SharingDataStore all protect mutations with kotlinx.coroutines.sync.Mutex. This ensures consistent state when concurrent coroutines update the same store.

Invalidate vs Clear​

  • invalidate() β€” marks the store as stale without erasing data. The UI continues displaying cached data while a refresh is triggered.
  • clear() β€” erases all data. Used on logout.

Persistent vs In-memory​

StorePersistenceMechanism
EncryptedTokenDataStoreDiskJetpack DataStore + Tink
AppPreferencesDataStoreDiskJetpack DataStore
WidgetDataStoreDiskSharedPreferences + Gson
All othersIn-memoryMutableStateFlow / MutableSharedFlow