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.
- DataStore Architecture β full API surface for every DataStore
- Real-Time WebSocket Architecture β WebSocket connection lifecycle, reconnection, and event routing
- Home Screen Widget β widget-specific offline behaviour
Architecture overviewβ
Key data flow:
DataStore inventoryβ
The app maintains eight DataStores across three persistence mechanisms:
| DataStore | Persistence | Mechanism | What it stores |
|---|---|---|---|
DeviceDataStore | In-memory | MutableStateFlow | Devices with latest positions, online/offline status |
GeofenceDataStore | In-memory | MutableStateFlow | Geofences with per-device lazy loading |
NotificationDataStore | In-memory | MutableStateFlow | Notification history (paginated), unread count |
InviteDataStore | In-memory | MutableStateFlow | Pending sharing invites for the current user |
SharingDataStore | In-memory | MutableStateFlow | Per-device share state (active shares + pending invites) |
EncryptedTokenDataStore | Disk | Jetpack DataStore + Tink AES-256-GCM | Auth tokens, user ID, email, name |
AppPreferencesDataStore | Disk | Jetpack DataStore Preferences | Theme mode, language preference |
WidgetDataStore | Disk | SharedPreferences + Gson | Device snapshots and auth tokens for widget process |
In-memory storesβ
All five in-memory DataStores (DeviceDataStore, GeofenceDataStore, NotificationDataStore, InviteDataStore, SharingDataStore) follow the same pattern:
@Singletonwith@Inject constructor()- Thread-safe via
kotlinx.coroutines.sync.Mutex - Expose read-only
StateFlowfor reactive observation - Most expose
SharedFlowfor one-shot event streams (position updates, new notifications, etc.) βGeofenceDataStoreis the exception as geofence mutations go through direct DataStore CRUD - Provide
clear()for logout; all exceptSharingDataStoreprovideinvalidate()for marking data as stale without erasing
Persistent storesβ
| Store | File | How it persists |
|---|---|---|
EncryptedTokenDataStore | visla_tokens (DataStore Preferences) | AES-256-GCM encryption via Google Tink, backed by Android Keystore master key |
AppPreferencesDataStore | visla_app_preferences (DataStore Preferences) | Plain Jetpack DataStore Preferences (theme/language are non-sensitive) |
WidgetDataStore | visla_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)β
| Feature | Implementation | Why |
|---|---|---|
| Device list | DeviceRepositoryImpl.getDevicesWithPositions() returns cached data if DeviceDataStore is non-empty; falls back to API only on empty cache | Users see their devices instantly on screen transitions; real-time WebSocket keeps data fresh |
| Geofences per device | GeofenceRepositoryImpl.getByDeviceId() checks isDeviceLoaded(deviceId) before hitting the API | Geofences rarely change; avoids redundant fetches when navigating between device details |
| Geofence by ID | GeofenceRepositoryImpl.getById() checks the DataStore first | Quick lookups when editing an existing geofence |
| Individual device | DeviceRepositoryImpl.getDevice() uses a MemoryCache with 30-second TTL | Device 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)β
| Feature | Implementation | Why |
|---|---|---|
| Device list on app launch | DevicesViewModel.loadDevices() calls devicesInteractor.refreshDevices() | Device status (online/offline) changes frequently; stale status is misleading |
| Pull-to-refresh | DeviceRepositoryImpl.refreshDevices() always hits API, then updates DataStore | Explicit user action signals desire for fresh data |
| Notifications | NotificationRepositoryImpl has no local cache; uses API pagination | History grows over time; server is the authoritative source |
| Events | EventRepositoryImpl always fetches from API | Time-ranged queries don't benefit from caching |
| Positions (history) | PositionRepositoryImpl always fetches from API | Historical data is queried by time range |
| Shares | SharingRepositoryImpl always fetches from API | Share 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)β
| Feature | Implementation | Why |
|---|---|---|
| Notification settings | NotificationRepositoryImpl.getSettings() / getDeviceSettings() | Settings are rarely read and must be accurate when displayed |
| Commands | CommandRepositoryImpl.send() | Fire-and-forget write operations |
| Device lifecycle | DeviceLifecycleRepositoryImpl.suspendDevice() / reactivateDevice() | State-changing operations that must hit the server |
Data flow: API β Repository β DataStore β UIβ
Initial loadβ
- ViewModel dispatches a
LoadDevicesintent - Interactor/Repository calls the REST API via Retrofit
- Repository maps DTOs to domain entities and writes them to the DataStore
- DataStore updates its
MutableStateFlow, which emits to all collectors - ViewModel collects the
StateFlowand updates_uiState - Compose UI recomposes based on the new state
Real-time updatesβ
- WebSocket receives a JSON event from the server
- WebSocketManager parses the event type and emits on a typed
SharedFlow - RealTimeDataBridge collects the flow and routes to the appropriate DataStore
- DataStore merges the update into its state (the same
StateFlowthat ViewModels observe) - ViewModel automatically receives the update β no polling or manual refresh needed
User-initiated mutationβ
- ViewModel calls
repository.updateDevice(id, name = newName) - Repository calls the API, maps the response, and writes to the DataStore
- 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β
| Trigger | What refreshes | Mechanism |
|---|---|---|
| App returns to foreground | Device list | ProcessLifecycleOwner lifecycle observer with 5-second debounce |
| WebSocket reconnects | All real-time data | RealTimeDataBridge re-subscribes to all event flows |
| Pull-to-refresh | Current screen's data | ViewModel calls refreshDevices() / refresh() |
| Notification screen visible | Notification history | OnScreenVisible intent triggers background API fetch |
| Share/unclaim action | WebSocket re-auth | RealTimeDataBridge.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:
| Constant | Duration | Use case |
|---|---|---|
DEFAULT_TTL_MILLIS | 30 seconds | Individual device details |
SHORT_TTL_MILLIS | 10 seconds | Frequently changing data |
LONG_TTL_MILLIS | 5 minutes | Rarely changing data |
Invalidation patternsβ
invalidate()β marks data as stale without erasing it. The UI continues showing cached data while a refresh loads in the backgroundclear()β erases all data. Called on logout to prevent data leakage between accountsinvalidateDevice(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β
- In-app sync β
DevicesViewModel.syncWidgetDevices()writes toWidgetDataStoreevery time the device list updates (from API or WebSocket) - Background sync β
WidgetSyncWorkerruns as a 15-minute periodic WorkManager job with aNetworkType.CONNECTEDconstraint - 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β
| Layer | Technology |
|---|---|
| Encryption primitive | Google Tink Aead (AES-256-GCM) |
| Key storage | Android Keystore (visla_token_master_key) |
| Keyset persistence | SharedPreferences (visla_token_prefs) |
| Data persistence | Jetpack 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β
| Field | Encrypted | Access 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:
| Store | DataStore variant | Reason |
|---|---|---|
EncryptedTokenDataStore | Preferences | Simple key-value pairs; encryption applied at the value level |
AppPreferencesDataStore | Preferences | Two 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.valueis a direct memory read - Automatic UI consistency β all ViewModels observing the same
StateFlowsee 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.