Home Screen Widget
The Visla GPS app ships a resizable Device Grid Widget that shows a compact grid of the user's GPS devices directly on the Android home screen. Each card displays the device name, icon, and online/mute status. Users can tap a card to toggle mute β the change is optimistically applied and rolled back on API failure.
Architecture overviewβ
All five source files live in a single widget package (com.visla.vislagps.widget):
| File | Role |
|---|---|
DeviceGridWidget.kt | AppWidgetProvider β lifecycle callbacks, WorkManager scheduling |
WidgetGridBuilder.kt | Builds RemoteViews, handles mute toggle and API calls |
WidgetSyncWorker.kt | CoroutineWorker β periodic device list sync |
WidgetDataStore.kt | SharedPreferences cache for devices and auth tokens |
WidgetDevice.kt | Lightweight data model for widget display |
DeviceGridWidgetβ
DeviceGridWidget extends the classic AppWidgetProvider (not Jetpack Glance β see Design Decisions).
Manifest declarationβ
<!-- AndroidManifest.xml -->
<receiver
android:name=".widget.DeviceGridWidget"
android:label="@string/widget_name"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.visla.vislagps.widget.TOGGLE_MUTE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/device_grid_widget_info" />
</receiver>
The receiver listens for two actions:
APPWIDGET_UPDATEβ standard system callback on widget add/refresh.TOGGLE_MUTEβ custom broadcast fired when a user taps a device card.
Widget info XMLβ
<!-- res/xml/device_grid_widget_info.xml -->
<appwidget-provider
android:minWidth="110dp"
android:minHeight="52dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:maxResizeWidth="530dp"
android:maxResizeHeight="530dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialLayout="@layout/widget_grid"
android:description="@string/widget_description"
android:widgetFeatures="reconfigurable" />
The widget starts at 2Γ1 cells and can be resized up to 530Γ530 dp in both directions.
Lifecycle callbacksβ
class DeviceGridWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
for (appWidgetId in appWidgetIds) {
val views = WidgetGridBuilder.buildRemoteViews(context, appWidgetManager, appWidgetId)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
schedulePeriodicSync(context)
}
override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager,
appWidgetId: Int, newOptions: Bundle) {
val views = WidgetGridBuilder.buildRemoteViews(context, appWidgetManager, appWidgetId)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == WidgetGridBuilder.ACTION_TOGGLE_MUTE) {
WidgetGridBuilder.handleToggleMute(context, intent)
}
}
override fun onEnabled(context: Context) {
super.onEnabled(context)
schedulePeriodicSync(context)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WorkManager.getInstance(context).cancelUniqueWork(WidgetSyncWorker.WORK_NAME)
}
}
onUpdaterebuilds every widget instance and ensures the sync worker is scheduled.onAppWidgetOptionsChangedrebuilds the grid when the user resizes the widget so the column/row count adapts.onReceiveintercepts the customTOGGLE_MUTEbroadcast and delegates toWidgetGridBuilder.onEnabled/onDisabledstart and stop the periodic background sync.
WidgetGridBuilderβ
WidgetGridBuilder is the rendering engine for the widget. It is a Kotlin object that builds RemoteViews and handles the mute toggle flow.
State managementβ
buildRemoteViews determines which of four states to display:
| Condition | Visible view | Tap action |
|---|---|---|
| No access token | login_prompt β "Sign in to Visla" | Opens MainActivity |
| Token present, never synced, no devices | loading_state β "Loadingβ¦" | Opens MainActivity |
| Token present, synced, no devices | empty_state β "No devices" | Opens MainActivity |
| Devices available | grid_container β device grid | Mute toggle per card |
when {
!hasToken -> {
views.setViewVisibility(R.id.login_prompt, View.VISIBLE)
views.setOnClickPendingIntent(R.id.login_prompt, launchAppPendingIntent(context, Int.MAX_VALUE))
}
devices.isEmpty() && !WidgetDataStore.hasEverSynced(context) -> {
views.setViewVisibility(R.id.loading_state, View.VISIBLE)
views.setOnClickPendingIntent(R.id.loading_state, launchAppPendingIntent(context, Int.MAX_VALUE - 1))
}
devices.isEmpty() -> {
views.setViewVisibility(R.id.empty_state, View.VISIBLE)
views.setOnClickPendingIntent(R.id.empty_state, launchAppPendingIntent(context, Int.MAX_VALUE - 2))
}
else -> buildGrid(context, views, devices, appWidgetManager, appWidgetId)
}
Responsive grid layoutβ
The grid dynamically calculates how many columns and rows fit based on the widget's reported dimensions.
Layout constants:
| Constant | Value | Purpose |
|---|---|---|
CARD_MIN_WIDTH_DP | 90 | Minimum card width to calculate column count |
CARD_HEIGHT_DP | 36 | Card height for row calculation |
GAP_DP | 4 | Spacing between cards |
OUTER_PADDING_DP | 6 | Widget outer padding |
MAX_COLUMNS | 2 | Hard cap on columns |
MAX_ROWS | 5 | Hard cap on rows |
VERTICAL_LAYOUT_THRESHOLD_DP | 60 | Card height above which the vertical layout is used |
Column/row calculation:
private fun calculateColumns(widthDp: Int): Int {
val available = widthDp - 2 * OUTER_PADDING_DP
return maxOf(1, (available + GAP_DP) / (CARD_MIN_WIDTH_DP + GAP_DP)).coerceAtMost(MAX_COLUMNS)
}
private fun calculateRows(heightDp: Int): Int {
val available = heightDp - 2 * OUTER_PADDING_DP
return maxOf(1, (available + GAP_DP) / (CARD_HEIGHT_DP + GAP_DP)).coerceAtMost(MAX_ROWS)
}
The widget reads the current dimensions from AppWidgetManager.getAppWidgetOptions, using MIN_WIDTH / MAX_HEIGHT in portrait and MAX_WIDTH / MIN_HEIGHT in landscape:
val widthDp = if (isPortrait) {
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, DEFAULT_WIDTH_DP)
} else {
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, DEFAULT_WIDTH_DP)
}
Two layout variants are used depending on the card height:
| Condition | Layout file | Orientation |
|---|---|---|
cardHeightDp >= 60 | widget_device_item_large.xml | Vertical (icon above name) |
cardHeightDp < 60 | widget_device_item.xml | Horizontal (icon left of name) |
Adaptive text and icon sizingβ
Text size and icon dimensions scale in three tiers based on the computed card height:
| Card height (dp) | Text size | Icon size |
|---|---|---|
β₯ 60 | 16 sp | 36 dp |
β₯ 40 | 14 sp | 28 dp |
< 40 | 11 sp | 20 dp |
Icon resizing uses setViewLayoutWidth / setViewLayoutHeight, which requires API 31+ (Build.VERSION_CODES.S). On older devices the icon stays at the XML-defined default size.
Device card renderingβ
Each card conveys three pieces of information visually:
- Online / offline status β icon tint color (
device_onlinegreen vsdevice_offlinered). - Muted state β background drawable switches between
widget_inner_cardandwidget_inner_card_muted(amber tint + orange border). - Device type β icon resolved from
getDeviceIconResId(), mapping emoji and string identifiers to drawable resources (car, truck, motorcycle, boat, person, pet, etc.).
val bgRes = if (device.isMuted) R.drawable.widget_inner_card_muted
else R.drawable.widget_inner_card
cardView.setInt(R.id.device_item_root, "setBackgroundResource", bgRes)
val iconTint = if (device.isOnline) context.getColor(R.color.device_online)
else context.getColor(R.color.device_offline)
cardView.setInt(R.id.device_item_icon, "setColorFilter", iconTint)
Grid constructionβ
Devices are chunked into rows, each wrapped in a widget_grid_row.xml horizontal LinearLayout. If a row has fewer devices than columns, invisible spacer views are added to maintain the grid alignment:
visibleDevices.chunked(columns).forEach { rowDevices ->
val rowView = RemoteViews(context.packageName, R.layout.widget_grid_row)
rowDevices.forEach { device ->
val cardView = RemoteViews(context.packageName, itemLayout)
configureDeviceCard(context, cardView, device, cardHeightDp)
rowView.addView(R.id.row_container, cardView)
}
repeat(columns - rowDevices.size) {
val spacer = RemoteViews(context.packageName, itemLayout)
spacer.setViewVisibility(R.id.device_item_root, View.INVISIBLE)
rowView.addView(R.id.row_container, spacer)
}
views.addView(R.id.grid_container, rowView)
}
Optimistic mute toggleβ
Tapping a device card fires a PendingIntent broadcast with the device ID and current mute state. The toggle follows an optimistic-update-with-rollback pattern:
fun handleToggleMute(context: Context, intent: Intent) {
val deviceId = intent.getIntExtra(EXTRA_DEVICE_ID, -1)
val isMuted = intent.getBooleanExtra(EXTRA_IS_MUTED, false)
if (deviceId == -1) return
val newMuteStatus = !isMuted
// 1. Optimistic update
WidgetDataStore.updateDeviceMuteStatus(context, deviceId, newMuteStatus)
updateAllWidgets(context)
// 2. API call with rollback on failure
val token = WidgetDataStore.getAccessToken(context) ?: return
Thread {
if (!callMuteApiWithRetry(context, deviceId, newMuteStatus, token)) {
revertMuteStatus(context, deviceId, isMuted)
}
}.start()
}
The API call targets POST /api/devices/{id}/mute or POST /api/devices/{id}/unmute. On a 401 Unauthorized, the code attempts one token refresh via POST /api/auth/refresh before giving up.
WidgetSyncWorkerβ
WidgetSyncWorker is a CoroutineWorker that periodically fetches the device list from the server and updates the widget.
Schedulingβ
The sync is registered as a unique periodic work with a 15-minute interval and a network-connectivity constraint:
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
)
}
ExistingPeriodicWorkPolicy.KEEP ensures that if a sync is already scheduled, duplicates are not created.
Sync logicβ
override suspend fun doWork(): Result {
val token = WidgetDataStore.getAccessToken(context) ?: return Result.failure()
val devices = fetchDevicesWithRetry(context, token) ?: return Result.retry()
val widgetDevices = devices
.sortedWith(
compareBy<DeviceApiResponse> { it.status != "online" }
.thenBy { it.name.lowercase() }
)
.map { device ->
WidgetDevice(
id = device.id, name = device.name, icon = device.icon,
isOnline = device.status == "online",
isSuspended = device.suspended, isMuted = device.isMuted ?: false
)
}
WidgetDataStore.saveDevices(context, widgetDevices)
WidgetGridBuilder.updateAllWidgets(context)
return Result.success()
}
Key behaviors:
- Sort order β online devices first, then alphabetically by name.
- Token refresh β on a 401, the worker calls
WidgetGridBuilder.refreshAccessToken()and retries once. - Retry vs failure β network errors return
Result.retry()(WorkManager will back off and retry); missing tokens returnResult.failure().
WidgetDataStoreβ
WidgetDataStore provides the widget's offline cache via SharedPreferences (file: visla_widget_prefs). It is deliberately separate from the app's main EncryptedTokenDataStore and in-memory DeviceDataStore because widgets run in a restricted process context where Hilt injection and encrypted storage are not available. See also DataStore Architecture.
Stored keysβ
| Key | Type | Description |
|---|---|---|
widget_devices | JSON string | Serialized List<WidgetDevice> |
widget_access_token | String | JWT access token |
widget_refresh_token | String | JWT refresh token |
widget_has_ever_synced | Boolean | Whether at least one sync has completed |
WidgetDevice modelβ
data class WidgetDevice(
val id: Int,
val name: String,
val icon: String?,
val isOnline: Boolean,
val isSuspended: Boolean,
val isMuted: Boolean
)
A minimal projection of the full Device domain model, carrying only the fields needed for widget display.
Synchronous commit for mute updatesβ
updateDeviceMuteStatus uses commit = true (synchronous write) instead of the default apply() (async) to guarantee data is persisted before updateAllWidgets() reads it back:
fun updateDeviceMuteStatus(context: Context, deviceId: Int, isMuted: Boolean) {
val devices = loadDevices(context).map { device ->
if (device.id == deviceId) device.copy(isMuted = isMuted) else device
}
val json = gson.toJson(devices)
getPrefs(context).edit(commit = true) { putString(KEY_DEVICES, json) }
}
Dark mode supportβ
The widget uses Android's resource-qualifier system for automatic dark mode. Colors are defined in both values/colors.xml and values-night/colors.xml:
| Token | Light | Dark |
|---|---|---|
widget_background | #FFFBFE | #0D0D1A |
widget_card_bg | #F0F0F5 | #1C1C2E |
widget_card_border | #E0E0E0 | #2A2A3E |
widget_name_text | #1C1B1F | #FFFFFF |
widget_text_secondary | #8A8A9A | #9E9EB0 |
widget_muted_tint | #FFF8E1 | #1A1005 |
widget_muted_border | #FF9800 | #FF9800 |
Drawables reference these color resources, so the outer card (widget_card_background) and inner cards (widget_inner_card, widget_inner_card_muted) automatically adapt. No runtime code is needed.
Layout filesβ
| File | Description |
|---|---|
widget_grid.xml | Root FrameLayout with four mutually-exclusive children: grid_container, empty_state, loading_state, login_prompt |
widget_grid_row.xml | Horizontal LinearLayout representing one row of the grid |
widget_device_item.xml | Compact card β horizontal layout (icon + name side by side) |
widget_device_item_large.xml | Tall card β vertical layout (icon above name) |
All card items use layout_weight="1" inside the row's horizontal LinearLayout to divide width equally.
Design decisionsβ
RemoteViews over Jetpack Glanceβ
The widget is built with the traditional AppWidgetProvider + RemoteViews API rather than Jetpack Glance. Reasons:
- Compatibility β Glance requires a Compose runtime and increases the APK size for a feature that runs outside the app process. The widget package has zero Compose dependencies.
- Predictable sizing β
RemoteViewsgives direct control overAppWidgetManager.getAppWidgetOptionsdimension queries, making the responsive grid calculation straightforward. - Stability β At the time of implementation, Glance was still in alpha/beta with limited widget feature parity (e.g., no
setViewLayoutWidthequivalent for dynamic icon sizing).
Separate data storeβ
WidgetDataStore duplicates tokens and device data from the main app's stores (EncryptedTokenDataStore, DeviceDataStore) into plain SharedPreferences. This is intentional:
- Widgets run via
BroadcastReceivercallbacks where Hilt's dependency graph is not initialized. EncryptedSharedPreferencescan throw on first access if the Android Keystore is locked (e.g., after direct boot).- A plain
SharedPreferencesfile is always readable from both the app and the widget provider.
The main app is responsible for writing tokens into WidgetDataStore at login and clearing them at logout. See Authentication and DataStore Architecture for details.
Optimistic UI with rollbackβ
The mute toggle applies the new state to the cache and rebuilds the UI before the API call completes. This gives the user instant visual feedback. If the API call fails (including after one token-refresh retry), the original state is restored and the UI is rebuilt again. The brief visual flicker on failure is an acceptable trade-off for the common-case responsiveness.
Icon mapping strategyβ
getDeviceIconResId maps both emoji characters (stored on the server) and plain-text aliases to local drawable resources. This avoids rendering emoji in RemoteViews (where emoji support varies by launcher) and keeps the icon set consistent with the app's design system.
Synchronous SharedPreferences commitβ
updateDeviceMuteStatus uses commit = true instead of apply(). In the optimistic toggle flow, updateAllWidgets() is called immediately after the write. With apply(), the async disk write might not complete before the subsequent loadDevices() read, causing the UI to show stale state. The synchronous commit eliminates this race condition.