Skip to main content

Offline & Caching Strategy

The Visla GPS Android app uses a DataStore-as-source-of-truth pattern where in-memory stores hold the canonical state of every feature area. Data enters via HTTP API or WebSocket, flows through repositories into DataStores, and is consumed reactively by ViewModels through Kotlin StateFlow / SharedFlow. There is no traditional offline-first database β€” the app is designed to work online but degrades gracefully when connectivity is temporarily lost.

Cross-references

Architecture overview​

Key data flow:


DataStore inventory​

The app maintains eight DataStores across three persistence mechanisms:

DataStorePersistenceMechanismWhat it stores
DeviceDataStoreIn-memoryMutableStateFlowDevices with latest positions, online/offline status
GeofenceDataStoreIn-memoryMutableStateFlowGeofences with per-device lazy loading
NotificationDataStoreIn-memoryMutableStateFlowNotification history (paginated), unread count
InviteDataStoreIn-memoryMutableStateFlowPending sharing invites for the current user
SharingDataStoreIn-memoryMutableStateFlowPer-device share state (active shares + pending invites)
EncryptedTokenDataStoreDiskJetpack DataStore + Tink AES-256-GCMAuth tokens, user ID, email, name
AppPreferencesDataStoreDiskJetpack DataStore PreferencesTheme mode, language preference
WidgetDataStoreDiskSharedPreferences + GsonDevice snapshots and auth tokens for widget process

In-memory stores​

All five in-memory DataStores (DeviceDataStore, GeofenceDataStore, NotificationDataStore, InviteDataStore, SharingDataStore) follow the same pattern:

  • @Singleton with @Inject constructor()
  • Thread-safe via kotlinx.coroutines.sync.Mutex
  • Expose read-only StateFlow for reactive observation
  • Most expose SharedFlow for one-shot event streams (position updates, new notifications, etc.) β€” GeofenceDataStore is the exception as geofence mutations go through direct DataStore CRUD
  • Provide clear() for logout; all except SharingDataStore provide invalidate() for marking data as stale without erasing

Persistent stores​

StoreFileHow it persists
EncryptedTokenDataStorevisla_tokens (DataStore Preferences)AES-256-GCM encryption via Google Tink, backed by Android Keystore master key
AppPreferencesDataStorevisla_app_preferences (DataStore Preferences)Plain Jetpack DataStore Preferences (theme/language are non-sensitive)
WidgetDataStorevisla_widget_prefs (SharedPreferences)Plain SharedPreferences for cross-process widget access

Cache-first vs network-first strategies​

The app uses different caching strategies depending on the data freshness requirements of each feature:

Cache-first (serve cached data, refresh in background)​

FeatureImplementationWhy
Device listDeviceRepositoryImpl.getDevicesWithPositions() returns cached data if DeviceDataStore is non-empty; falls back to API only on empty cacheUsers see their devices instantly on screen transitions; real-time WebSocket keeps data fresh
Geofences per deviceGeofenceRepositoryImpl.getByDeviceId() checks isDeviceLoaded(deviceId) before hitting the APIGeofences rarely change; avoids redundant fetches when navigating between device details
Geofence by IDGeofenceRepositoryImpl.getById() checks the DataStore firstQuick lookups when editing an existing geofence
Individual deviceDeviceRepositoryImpl.getDevice() uses a MemoryCache with 30-second TTLDevice detail needs full permissions which the list endpoint doesn't include
// GeofenceRepositoryImpl β€” cache-first per device
override suspend fun getByDeviceId(deviceId: Int): List<Geofence> {
if (geofenceDataStore.isDeviceLoaded(deviceId)) {
return geofenceDataStore.getGeofencesByDevice(deviceId)
}
// ... fetch from API
}
// DeviceRepositoryImpl β€” cache-first with network fallback
override suspend fun getDevicesWithPositions(): List<DeviceWithPosition> {
val cachedDevices = deviceDataStore.devicesWithPositions.value
if (cachedDevices.isNotEmpty()) {
return cachedDevices
}
// ... fetch from API, fall back to cache on error
}

Network-first (always fetch from API, cache for resilience)​

FeatureImplementationWhy
Device list on app launchDevicesViewModel.loadDevices() calls devicesInteractor.refreshDevices()Device status (online/offline) changes frequently; stale status is misleading
Pull-to-refreshDeviceRepositoryImpl.refreshDevices() always hits API, then updates DataStoreExplicit user action signals desire for fresh data
NotificationsNotificationRepositoryImpl has no local cache; uses API paginationHistory grows over time; server is the authoritative source
EventsEventRepositoryImpl always fetches from APITime-ranged queries don't benefit from caching
Positions (history)PositionRepositoryImpl always fetches from APIHistorical data is queried by time range
SharesSharingRepositoryImpl always fetches from APIShare state must be accurate for permission-sensitive operations
// DevicesViewModel β€” network-first on app launch
private fun loadDevices() {
viewModelScope.launch {
// Always fetch fresh data from API to avoid stale cached status
val devicesWithPos = sortDevices(devicesInteractor.refreshDevices())
// ...
}
}

Network-only (no caching)​

FeatureImplementationWhy
Notification settingsNotificationRepositoryImpl.getSettings() / getDeviceSettings()Settings are rarely read and must be accurate when displayed
CommandsCommandRepositoryImpl.send()Fire-and-forget write operations
Device lifecycleDeviceLifecycleRepositoryImpl.suspendDevice() / reactivateDevice()State-changing operations that must hit the server

Data flow: API β†’ Repository β†’ DataStore β†’ UI​

Initial load​

  1. ViewModel dispatches a LoadDevices intent
  2. Interactor/Repository calls the REST API via Retrofit
  3. Repository maps DTOs to domain entities and writes them to the DataStore
  4. DataStore updates its MutableStateFlow, which emits to all collectors
  5. ViewModel collects the StateFlow and updates _uiState
  6. Compose UI recomposes based on the new state

Real-time updates​

  1. WebSocket receives a JSON event from the server
  2. WebSocketManager parses the event type and emits on a typed SharedFlow
  3. RealTimeDataBridge collects the flow and routes to the appropriate DataStore
  4. DataStore merges the update into its state (the same StateFlow that ViewModels observe)
  5. ViewModel automatically receives the update β€” no polling or manual refresh needed

User-initiated mutation​

  1. ViewModel calls repository.updateDevice(id, name = newName)
  2. Repository calls the API, maps the response, and writes to the DataStore
  3. All ViewModels observing the DataStore see the change immediately
// DeviceDetailViewModel β€” observes DataStore for real-time updates
init {
viewModelScope.launch {
deviceDataStore.devicesWithPositions
.map { devices -> devices.find { it.device.id == currentDeviceId } }
.distinctUntilChanged()
.collect { deviceWithPosition ->
if (deviceWithPosition != null && currentDeviceId > 0) {
_uiState.value = _uiState.value.copy(
device = deviceWithPosition.device,
position = deviceWithPosition.position
)
}
}
}
}

Stale data handling and refresh policies​

Automatic refresh triggers​

TriggerWhat refreshesMechanism
App returns to foregroundDevice listProcessLifecycleOwner lifecycle observer with 5-second debounce
WebSocket reconnectsAll real-time dataRealTimeDataBridge re-subscribes to all event flows
Pull-to-refreshCurrent screen's dataViewModel calls refreshDevices() / refresh()
Notification screen visibleNotification historyOnScreenVisible intent triggers background API fetch
Share/unclaim actionWebSocket re-authRealTimeDataBridge.reconnect() to get new device permissions

Foreground refresh​

DevicesViewModel registers a ProcessLifecycleOwner observer that refreshes devices when the app returns to the foreground, with a 5-second debounce to avoid rapid re-fetches:

private val lifecycleObserver = object : DefaultLifecycleObserver {
private var isFirstStart = true
private var lastForegroundTime = 0L

override fun onStart(owner: LifecycleOwner) {
if (isFirstStart) { isFirstStart = false; return }
val now = System.currentTimeMillis()
if (now - lastForegroundTime < 5000) return
lastForegroundTime = now
handle(DeviceIntent.RefreshDevices)
}
}

TTL-based cache (MemoryCache)​

DeviceRepositoryImpl uses a MemoryCache<Int, Device> with a 30-second TTL for individual device lookups. This is a legacy pattern kept for getDevice() which needs per-device permissions that the list endpoint doesn't return:

override suspend fun getDevice(id: Int): Device = deviceCache.getOrPut(id) {
val response = deviceApi.getDevice(id)
deviceMapper.toDomain(response)
}

The MemoryCache supports three TTL tiers:

ConstantDurationUse case
DEFAULT_TTL_MILLIS30 secondsIndividual device details
SHORT_TTL_MILLIS10 secondsFrequently changing data
LONG_TTL_MILLIS5 minutesRarely changing data

Invalidation patterns​

  • invalidate() β€” marks data as stale without erasing it. The UI continues showing cached data while a refresh loads in the background
  • clear() β€” erases all data. Called on logout to prevent data leakage between accounts
  • invalidateDevice(deviceId) (GeofenceDataStore) β€” marks a single device's geofences as stale, used when a share is revoked
// On logout β€” clear all DataStores
override suspend fun logout() {
realTimeDataBridge.stop()
deviceDataStore.clear()
geofenceDataStore.clear()
notificationDataStore.clear()
inviteDataStore.clear()
sharingDataStore.clear()
// ...
}

Network error fallback​

DeviceRepositoryImpl.getDevicesWithPositions() demonstrates graceful degradation β€” if the API call fails but cached data exists, it returns the cached data:

} catch (e: HttpException) {
val cached = deviceDataStore.devicesWithPositions.value
if (cached.isNotEmpty()) {
Logger.warn("API failed, returning ${cached.size} cached devices", emptyMap())
cached
} else {
throw mapHttpException(e)
}
}

Widget offline behaviour​

The home-screen widget operates in a separate process and needs its own offline strategy. See Widget documentation for full details.

WidgetDataStore​

Uses plain SharedPreferences (not Jetpack DataStore) because widgets need synchronous reads from a separate process:

object WidgetDataStore {
private const val PREFS_NAME = "visla_widget_prefs"
// Stores: device list (JSON), access token, refresh token, sync flag
}

Sync strategy​

  1. In-app sync β€” DevicesViewModel.syncWidgetDevices() writes to WidgetDataStore every time the device list updates (from API or WebSocket)
  2. Background sync β€” WidgetSyncWorker runs as a 15-minute periodic WorkManager job with a NetworkType.CONNECTED constraint
  3. Token refresh β€” the worker handles 401 responses by calling WidgetGridBuilder.refreshAccessToken() before retrying
// DeviceGridWidget β€” schedules periodic sync
companion object {
private const val SYNC_INTERVAL_MINUTES = 15L

fun schedulePeriodicSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<WidgetSyncWorker>(
SYNC_INTERVAL_MINUTES, TimeUnit.MINUTES
).setConstraints(constraints).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WidgetSyncWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}

Widget offline fallback​

When the widget has no network access:

  • It renders from WidgetDataStore.loadDevices() β€” the last successfully synced device snapshot
  • hasEverSynced() determines whether to show a "not yet synced" placeholder
  • Mute toggle uses optimistic UI with commit = true (synchronous write) then attempts the API call; rolls back on failure

WebSocket reconnection and data sync​

See Real-Time WebSocket Architecture for the full reconnection protocol. This section covers how data consistency is restored after a connection drop.

Pending updates queue​

WebSocket events that arrive before DeviceDataStore.isInitialized becomes true are buffered in CopyOnWriteArrayList queues. This prevents race conditions when the WebSocket connects before the initial API load completes:

private fun setupPendingUpdatesProcessor(
pendingPositions: CopyOnWriteArrayList<Position>,
pendingStatusUpdates: CopyOnWriteArrayList<Pair<Int, String>>
) {
scope?.launch {
deviceDataStore.isInitialized.first { it }
// Drain queued updates
pendingPositions.forEach { deviceDataStore.updatePosition(it) }
pendingPositions.clear()
pendingStatusUpdates.forEach { (id, status) ->
deviceDataStore.updateDeviceStatus(id, status)
}
pendingStatusUpdates.clear()
}
}

Post-reconnection refresh​

When the WebSocket reconnects after a disconnection gap, the app has no way to know what events were missed. The DevicesViewModel foreground observer handles this by refreshing the full device list on every app-to-foreground transition.

Permission-triggered reconnection​

After claiming a device or accepting a share invite, the WebSocket must reconnect so the server includes events for the new device:

fun reconnect() {
realTimeRepository.disconnect()
realTimeRepository.connect()
}

Encrypted persistence​

EncryptedTokenDataStore uses Jetpack DataStore Preferences with Google Tink AES-256-GCM encryption to protect authentication tokens and user identity at rest.

Encryption stack​

LayerTechnology
Encryption primitiveGoogle Tink Aead (AES-256-GCM)
Key storageAndroid Keystore (visla_token_master_key)
Keyset persistenceSharedPreferences (visla_token_prefs)
Data persistenceJetpack DataStore Preferences (visla_tokens)

Self-healing on key corruption​

If the Android Keystore key becomes corrupted (e.g. after OS update or factory reset restore), the DataStore resets gracefully:

private val aead: Aead by lazy {
AeadConfig.register()
try { buildAead() }
catch (e: java.security.InvalidKeyException) {
resetKeystoreAndPrefs() // Delete keystore entry, clear prefs, clear DataStore
buildAead() // Recreate from scratch β€” user will need to re-login
}
}

Stored fields​

FieldEncryptedAccess pattern
access_tokenβœ…Flow<String?> + getAccessTokenSync()
refresh_tokenβœ…Flow<String?> + getRefreshTokenSync()
user_id❌Flow<Int?> + getUserIdSync()
user_emailβœ…Flow<String?> + getUserEmailSync()
user_nameβœ…Flow<String?> + getUserNameSync()

Synchronous accessors (*Sync()) use runBlocking and are needed by OkHttp interceptors/authenticators that run outside coroutine contexts.


Proto DataStore vs Preferences DataStore​

The app does not use Proto DataStore. All persistent DataStores use Jetpack Preferences DataStore:

StoreDataStore variantReason
EncryptedTokenDataStorePreferencesSimple key-value pairs; encryption applied at the value level
AppPreferencesDataStorePreferencesTwo string settings (theme + language) don't warrant a schema

The in-memory DataStores (DeviceDataStore, GeofenceDataStore, etc.) don't use Jetpack DataStore at all β€” they hold state in MutableStateFlow and are not persisted to disk.

WidgetDataStore uses raw SharedPreferences rather than Jetpack DataStore because widget RemoteViewsFactory requires synchronous reads from a potentially different process.


Design decisions​

Why in-memory DataStores instead of Room / SQLite?​

The app's data model is inherently real-time. Device positions update every few seconds via WebSocket. A disk-backed database would add write latency and complexity (schema migrations, DAO boilerplate) for data that is only valid while the app is running. The in-memory approach provides:

  • Zero-latency reads β€” StateFlow.value is a direct memory read
  • Automatic UI consistency β€” all ViewModels observing the same StateFlow see identical data
  • Simple invalidation β€” clear() on logout, invalidate() on stale data
  • No schema migrations β€” domain entity changes are handled by Kotlin data classes

The trade-off is that data is lost on process death. This is acceptable because the app always fetches from the API on cold start.

Why not TTL-based caching everywhere?​

Early versions used MemoryCache with TTL-based expiration for device lists. This caused problems:

  • Stale device status (showing "online" for an offline device) until the TTL expired
  • Cache misses causing visible loading spinners on screen transitions
  • No way to handle WebSocket updates atomically with cached data

The DataStore pattern replaced TTL-based caching for list data. MemoryCache is retained only for individual device detail lookups where the list endpoint doesn't include all fields (e.g., permissions).

Why SharedPreferences for the widget instead of DataStore?​

Widgets run via AppWidgetProvider.onUpdate() which executes in a BroadcastReceiver context. Jetpack DataStore is asynchronous and requires a coroutine scope, which is incompatible with the synchronous onUpdate() lifecycle. SharedPreferences provides the synchronous cross-process reads that RemoteViews construction requires.

Why a separate WidgetSyncWorker instead of sharing the main DataStore?​

The widget may need to update when the main app process is killed. WidgetSyncWorker uses its own OkHttpClient and makes direct API calls rather than going through the app's repository layer. This ensures the widget stays up-to-date even without the main app running.

Why no offline-first / write-ahead queue?​

The Visla GPS app is a monitoring application β€” users read device positions and status, they don't generate content that needs to sync later. The only write operations (rename device, toggle mute, send command) are lightweight and require server confirmation to be meaningful. An offline write queue would add complexity without providing real user value for this use case.