Skip to main content

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):

FileRole
DeviceGridWidget.ktAppWidgetProvider β€” lifecycle callbacks, WorkManager scheduling
WidgetGridBuilder.ktBuilds RemoteViews, handles mute toggle and API calls
WidgetSyncWorker.ktCoroutineWorker β€” periodic device list sync
WidgetDataStore.ktSharedPreferences cache for devices and auth tokens
WidgetDevice.ktLightweight 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)
}
}
  • onUpdate rebuilds every widget instance and ensures the sync worker is scheduled.
  • onAppWidgetOptionsChanged rebuilds the grid when the user resizes the widget so the column/row count adapts.
  • onReceive intercepts the custom TOGGLE_MUTE broadcast and delegates to WidgetGridBuilder.
  • onEnabled / onDisabled start 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:

ConditionVisible viewTap action
No access tokenlogin_prompt β€” "Sign in to Visla"Opens MainActivity
Token present, never synced, no devicesloading_state β€” "Loading…"Opens MainActivity
Token present, synced, no devicesempty_state β€” "No devices"Opens MainActivity
Devices availablegrid_container β€” device gridMute 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:

ConstantValuePurpose
CARD_MIN_WIDTH_DP90Minimum card width to calculate column count
CARD_HEIGHT_DP36Card height for row calculation
GAP_DP4Spacing between cards
OUTER_PADDING_DP6Widget outer padding
MAX_COLUMNS2Hard cap on columns
MAX_ROWS5Hard cap on rows
VERTICAL_LAYOUT_THRESHOLD_DP60Card 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:

ConditionLayout fileOrientation
cardHeightDp >= 60widget_device_item_large.xmlVertical (icon above name)
cardHeightDp < 60widget_device_item.xmlHorizontal (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 sizeIcon size
β‰₯ 6016 sp36 dp
β‰₯ 4014 sp28 dp
< 4011 sp20 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:

  1. Online / offline status β€” icon tint color (device_online green vs device_offline red).
  2. Muted state β€” background drawable switches between widget_inner_card and widget_inner_card_muted (amber tint + orange border).
  3. 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 return Result.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​

KeyTypeDescription
widget_devicesJSON stringSerialized List<WidgetDevice>
widget_access_tokenStringJWT access token
widget_refresh_tokenStringJWT refresh token
widget_has_ever_syncedBooleanWhether 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:

TokenLightDark
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​

FileDescription
widget_grid.xmlRoot FrameLayout with four mutually-exclusive children: grid_container, empty_state, loading_state, login_prompt
widget_grid_row.xmlHorizontal LinearLayout representing one row of the grid
widget_device_item.xmlCompact card β€” horizontal layout (icon + name side by side)
widget_device_item_large.xmlTall 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:

  1. 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.
  2. Predictable sizing β€” RemoteViews gives direct control over AppWidgetManager.getAppWidgetOptions dimension queries, making the responsive grid calculation straightforward.
  3. Stability β€” At the time of implementation, Glance was still in alpha/beta with limited widget feature parity (e.g., no setViewLayoutWidth equivalent 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 BroadcastReceiver callbacks where Hilt's dependency graph is not initialized.
  • EncryptedSharedPreferences can throw on first access if the Android Keystore is locked (e.g., after direct boot).
  • A plain SharedPreferences file 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.