Skip to main content

Push Notifications

How the Visla GPS Android app receives, processes, and displays Firebase Cloud Messaging (FCM) push notifications β€” from token registration through to system tray display and in-app navigation.

Data-layer details

API endpoints, DTOs, repository implementation, and the NotificationDataStore are covered in data-layer/notifications.md. This document focuses on the FCM service, notification channels, push handling, and the WebSocket notification bridge.


Architecture Overview​

Parallel path β€” WebSocket notifications: While the app is in the foreground, the WebSocket connection also delivers notification events. These are bridged into the same NotificationDataStore via RealTimeDataBridge, providing instant in-app updates without relying on FCM latency. See WebSocket Notification Bridge and real-time.md.


FCMService​

com.visla.vislagps.core.service.FCMService β€” extends FirebaseMessagingService.

Declared in AndroidManifest.xml:

<service
android:name=".core.service.FCMService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<!-- Default channel for background notifications -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="visla_gps_channel" />

<!-- Default icon and color -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@mipmap/ic_launcher" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />

Token Refresh​

When FCM rotates the device token, onNewToken() stores it in NotificationStateRepository:

override fun onNewToken(token: String) {
super.onNewToken(token)
notificationRepository.setToken(token)
}

The repository is accessed via VislaGPSApp.notificationRepository (a static accessor) because FCMService is instantiated by Firebase outside of Hilt's DI graph.

Message Handling​

onMessageReceived() processes both notification and data payloads:

override fun onMessageReceived(message: RemoteMessage) {
val title = message.notification?.title ?: message.data["title"] ?: "VislaGPS"
val body = message.notification?.body ?: message.data["body"] ?: "New event received"
val data = message.data

// Sound priority: notification object > data payload > "default"
val soundValue = message.notification?.sound
?: data["sound"]
?: "default"

// Critical alarm detection
val alarmType = data["alarm_type"]
val isCritical = data["critical"] == "true"
|| VislaGPSApp.isCriticalAlarm(alarmType)

// Log to in-app notification list
notificationRepository.addNotification(title, body, data)

// Show system notification
showSystemNotification(title, body, data, soundValue, alarmType)
}

Push Payload Fields​

The server sends data payloads with these keys:

KeyTypeDescription
titleStringNotification title (fallback if no notification object)
bodyStringNotification body text
device_idStringGPS tracker device ID
event_typeStringEvent type (see NotificationEventType)
alarm_typeStringAlarm category (sos, tampering, geofence, etc.)
soundStringSound file name (e.g., alert_classic.wav)
criticalString"true" for critical alerts
latitudeStringDevice latitude at event time
longitudeStringDevice longitude at event time

Token Registration Flow​

Token registration involves three components:

PushTokenManager Interface​

com.visla.vislagps.domain.services.PushTokenManager β€” a clean domain interface abstracting the platform:

interface PushTokenManager {
val fcmToken: StateFlow<String?>
fun fetchToken(scope: CoroutineScope)
fun registerTokenWithBackend(scope: CoroutineScope)
fun clearToken()
}

Bound via Hilt in ServiceModule:

@Binds
@Singleton
abstract fun bindPushTokenManager(impl: FCMTokenManagerImpl): PushTokenManager

FCMTokenManagerImpl​

com.visla.vislagps.core.service.FCMTokenManagerImpl β€” handles the full lifecycle:

  1. Fetch token from FirebaseMessaging.getInstance().token
  2. Store in NotificationStateRepository.fcmToken
  3. Register with backend if user is logged in
override fun fetchToken(scope: CoroutineScope) {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
notificationStateRepository.setToken(token)

// If already logged in, register token
if (tokenManager.isLoggedIn) {
registerTokenWithBackend(scope)
}
} else {
handleTokenFetchError(task.exception, isRetry = false, scope)
}
}
}

Backend Registration​

override fun registerTokenWithBackend(scope: CoroutineScope) {
val token = notificationStateRepository.fcmToken.value ?: return
val userId = tokenManager.userId ?: return

scope.launch {
val result = registerPushTokenUseCase(
userId = userId,
token = token,
deviceName = Build.MODEL // e.g., "Pixel 8 Pro"
)
}
}

The backend API: POST /api/notifications/tokens with body { user_id, token, platform: "android", device_name }.

TOO_MANY_REGISTRATIONS Recovery​

If Firebase returns TOO_MANY_REGISTRATIONS, the manager deletes the existing instance ID and retries after a 1-second delay:

private fun handleTokenFetchError(error: Exception?, isRetry: Boolean, scope: CoroutineScope) {
if (errorMessage.contains("TOO_MANY_REGISTRATIONS") && !isRetry) {
FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { deleteTask ->
if (deleteTask.isSuccessful) {
Handler(Looper.getMainLooper()).postDelayed({
fetchTokenInternal(scope, isRetry = true)
}, NetworkConstants.RETRY_DELAY_MS) // 1000ms
}
}
}
}

Registration Trigger Points​

TriggerWhereWhy
App startupMainActivity.onCreate()pushTokenManager.fetchToken(lifecycleScope) β€” always fetch, auto-registers if logged in
Permission grantedrequestPermissionLauncher callbackFetch token after user grants POST_NOTIFICATIONS
Login / verification successAuthContent composableonFCMTokenRegister() β†’ registerTokenWithBackend()
OAuth loginprocessIntent()After OAuthLoginComplete deep link
Terms acceptedTermsAcceptanceScreenAfter user accepts terms
Token refreshFCMService.onNewToken()Firebase rotated the token

Push β†’ Navigation (Deep Linking from Notifications)​

When a user taps a system notification, the app navigates to the relevant device on the map.

Intent Construction (FCMService)​

FCMService.showSystemNotification() creates a deep-link intent:

val intent = Intent(this, MainActivity::class.java).apply {
action = "com.visla.vislagps.NOTIFICATION_TAP"
setData("visla://notification/$notificationId".toUri())
addFlags(FLAG_ACTIVITY_SINGLE_TOP or FLAG_ACTIVITY_NEW_TASK)

putExtra(EXTRA_FROM_NOTIFICATION, true)
putExtra(EXTRA_DEVICE_ID, deviceId)
putExtra(EXTRA_NOTIFICATION_TITLE, title)
putExtra(EXTRA_NOTIFICATION_BODY, body)
putExtra(EXTRA_EVENT_TYPE, eventType)
putExtra(EXTRA_TIMESTAMP, System.currentTimeMillis())
latitude?.let { putExtra(EXTRA_LATITUDE, it) }
longitude?.let { putExtra(EXTRA_LONGITUDE, it) }
}

Key details:

  • SINGLE_TOP flag reuses the existing activity (triggers onNewIntent)
  • Unique URI per notification (visla://notification/{id}) prevents Android from caching intents
  • FLAG_MUTABLE on Android 12+ allows extras to be updated

PushNotificationHandler​

com.visla.vislagps.ui.handlers.PushNotificationHandler extracts intent data and converts it to domain models:

@Singleton
class PushNotificationHandler @Inject constructor(
private val parsePushNotificationUseCase: ParsePushNotificationUseCase,
private val notificationStateRepository: NotificationStateRepository
) {
fun handleIntent(intent: Intent?): Boolean {
val data = extractIntentData(intent ?: return false)
val navigation = parsePushNotificationUseCase(data) ?: return false

notificationStateRepository.setPendingNavigation(
deviceId = navigation.deviceId,
title = navigation.title,
body = navigation.body,
eventType = navigation.eventType?.name,
timestamp = navigation.timestamp,
latitude = navigation.latitude,
longitude = navigation.longitude
)

clearIntentExtras(intent)
return true
}
}

The handler supports two intent formats:

  1. Foreground β€” extras from FCMService with EXTRA_FROM_NOTIFICATION = true
  2. Background β€” FCM data payload delivered directly as intent extras (raw key names like device_id)

ParsePushNotificationUseCase​

Converts PushNotificationIntentData β†’ NotificationNavigation:

class ParsePushNotificationUseCase @Inject constructor() {
operator fun invoke(data: PushNotificationIntentData): NotificationNavigation? {
if (!data.isFromNotification) return null
val deviceId = data.deviceId?.toIntOrNull() ?: return null
val eventType = NotificationEventType.fromString(data.eventType)
val title = data.title?.takeIf { it.isNotEmpty() }
?: NotificationEventType.getDisplayTitle(eventType)

return NotificationNavigation(deviceId, title, data.body ?: "", eventType, ...)
}
}

The MapViewModel observes notificationStateRepository.pendingNavigation and centers the map on the device when a pending navigation is consumed. Intent extras are cleared after processing to prevent re-navigation on configuration changes.


Notification Channels​

Android 8.0+ (API 26) requires notification channels. Channels are created at app startup in VislaGPSApp.onCreate().

Channel Types​

Channel IDNameImportanceBehavior
visla_gps_channelVisla GPS NotificationsHIGHDefault sound, vibration
visla_gps_channel_{soundKey}Visla GPS ({soundKey})HIGHCustom sound from res/raw/
visla_critical_alertsAllarmi CriticiHIGHBypasses DND, alarm-level audio

Default Channel​

private fun createNotificationChannel() {
createChannelForSound("default")
createCriticalAlertsChannel()
}

Dynamic Sound Channels​

Because Android locks a channel's sound after creation, the app creates a separate channel per sound:

fun createChannelForSound(soundKey: String): String {
val channelId = getChannelIdForSound(soundKey)
// e.g., "visla_gps_channel_alert_classic"

if (notificationManager.getNotificationChannel(channelId) != null) {
return channelId // Already exists
}

val channel = buildNotificationChannel(channelId, soundKey)
notificationManager.createNotificationChannel(channel)
return channelId
}

Sound URI resolution:

private fun getSoundUriForKey(soundKey: String): Uri? {
if (soundKey == "default" || soundKey == "none") return null
val resId = resources.getIdentifier(soundKey, "raw", packageName)
return if (resId != 0) {
"${ContentResolver.SCHEME_ANDROID_RESOURCE}://$packageName/$resId".toUri()
} else null
}

Critical Alerts Channel​

For SOS and tampering alarms, a special channel bypasses Do Not Disturb:

private fun createCriticalAlertsChannel() {
val channel = NotificationChannel(
CRITICAL_CHANNEL_ID,
"Allarmi Critici",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Allarmi urgenti (SOS, Manomissione) - Bypassa Non Disturbare"
enableVibration(true)
setBypassDnd(true)
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM) // Alarm-level volume
.build()
setSound(Settings.System.DEFAULT_ALARM_ALERT_URI, audioAttributes)
}
notificationManager.createNotificationChannel(channel)
}

Channel Routing​

At notification time, FCMService selects the channel:

val channelId = VislaGPSApp.getChannelForAlarm(alarmType, soundKey)
companion object {
private val CRITICAL_ALARM_TYPES = setOf("sos", "tampering")

fun isCriticalAlarm(alarmType: String?): Boolean =
alarmType?.lowercase() in CRITICAL_ALARM_TYPES

fun getChannelForAlarm(alarmType: String?, soundKey: String): String =
if (isCriticalAlarm(alarmType)) CRITICAL_CHANNEL_ID
else ensureChannelExists(soundKey)
}

Notification Event Types​

com.visla.vislagps.domain.entities.NotificationEventType β€” the domain enum for push event classification:

enum class NotificationEventType {
ALARM,
GEOFENCE_ENTER,
GEOFENCE_EXIT,
LOW_BATTERY,
SOS,
IGNITION_ON,
IGNITION_OFF,
DEVICE_MOVING,
DEVICE_STOPPED,
UNKNOWN;

companion object {
fun fromString(value: String?): NotificationEventType = when (value?.lowercase()) {
"alarm" -> ALARM
"geofence_enter", "geofenceenter" -> GEOFENCE_ENTER
"geofence_exit", "geofenceexit" -> GEOFENCE_EXIT
"low_battery", "lowbattery" -> LOW_BATTERY
"sos" -> SOS
"ignition_on", "ignitionon" -> IGNITION_ON
"ignition_off", "ignitionoff" -> IGNITION_OFF
"device_moving", "devicemoving" -> DEVICE_MOVING
"device_stopped", "devicestopped" -> DEVICE_STOPPED
else -> UNKNOWN
}
}
}

Each type maps to a display title (e.g., SOS β†’ "SOS Alert", GEOFENCE_ENTER β†’ "Geofence Entered").


Sound Customization​

Available Sounds​

Sound files are stored in res/raw/:

ResourceFile
alert_classicalert_classic.wav

The server returns the full list of available sounds via GET /api/notifications/sounds β†’ SoundsResponse.

Per-Category Sound Preferences​

Users can assign different sounds to alarm categories via the API:

// Get user's sound preferences
@GET("api/notifications/users/{userId}/sound-preferences")
suspend fun getSoundPreferences(@Path("userId") userId: Int): SoundPreferencesResponse

// Set sound for a category
@PUT("api/notifications/users/{userId}/sound-preferences/{category}")
suspend fun updateSoundPreference(
@Path("userId") userId: Int,
@Path("category") category: String,
@Body request: UpdateSoundPreferenceRequest // { sound_id: String }
)

// Reset to default
@DELETE("api/notifications/users/{userId}/sound-preferences/{category}")
suspend fun resetSoundPreference(...)

Alarm categories are fetched from GET /api/notifications/alarm-categories.

How Sound Reaches the Device​

  1. Server includes sound field in push payload (e.g., "alert_classic.wav")
  2. FCMService strips the extension: "alert_classic.wav" β†’ "alert_classic"
  3. Channel is selected/created for that sound key
  4. Android plays the channel's configured sound
val soundKey = soundValue.substringBeforeLast(".")
val channelId = VislaGPSApp.getChannelForAlarm(alarmType, soundKey)

In-App Notification History​

The app shows notification history fetched from the backend, not from the local push log.

Data Flow​

NotificationViewModel​

com.visla.vislagps.ui.screens.NotificationViewModel β€” MVI ViewModel managing notification screen state:

State: NotificationUiState β€” sealed class with Loading, Loaded, and Error variants.

Intents:

sealed class NotificationIntent : BaseIntent {
data object Refresh : NotificationIntent()
data object LoadNotificationHistory : NotificationIntent()
data object LoadMoreNotifications : NotificationIntent()
data class MarkNotificationAsRead(val notificationId: Int) : NotificationIntent()
data object ConsumeScrollToTop : NotificationIntent()
data object OnScreenVisible : NotificationIntent()
}

Key behaviors:

  • Pagination: Loads 10 items per page (PAGE_SIZE = 10), supports infinite scroll via LoadMoreNotifications
  • Smart loading: Only shows a loading spinner when no cached data exists; background refreshes are silent
  • Scroll-to-top: When a new notification arrives (via push or WebSocket), the list auto-scrolls to the top
  • Device names: Enriches notifications with device names from DeviceDataStore
  • Timezone: Applies user's timezone for timestamp display

Three Update Sources​

The ViewModel observes three distinct notification sources:

  1. Push count changes β€” notificationStateRepository.notificationCount triggers a background API refresh
  2. WebSocket events β€” notificationDataStore.newNotificationEvent inserts directly into the list (deduplicates by ID)
  3. Screen visibility β€” OnScreenVisible intent triggers a background refresh to catch notifications received while app was backgrounded

WebSocket Notification Bridge​

While push notifications handle delivery when the app is backgrounded or killed, the WebSocket provides instant in-app updates when the app is in the foreground.

How It Works​

The RealTimeDataBridge subscribes to notification events from the WebSocket:

private fun setupNotificationEvents() {
scope?.launch {
realTimeRepository.notificationReceived
.collect { notification ->
notificationDataStore.addNotification(notification, emitEvent = true)
}
}
}

The WebSocket "notification" event type is parsed in WebSocketManager:

"notification" -> handleNotification(data)

private fun handleNotification(data: JsonObject) {
val dto = gson.fromJson(data, WsNotificationDto::class.java)
val notification = mapper.toDomain(dto)
_notificationReceived.tryEmit(notification)
}

Push vs. WebSocket​

AspectFCM PushWebSocket
When activeAlways (background, killed, foreground)Only while app is in foreground
LatencySeconds (via Google servers)Milliseconds (direct connection)
System notificationYes β€” shows in system trayNo β€” in-app list only
In-app list updateIncrements notificationCount β†’ triggers API refreshInserts directly into NotificationDataStore
DeduplicationN/A (different purpose)By notification.id in DataStore

Both paths ultimately feed into the same NotificationDataStore, which the NotificationViewModel observes. See real-time.md for the full WebSocket architecture.


Notification Permission​

On Android 13+ (API 33), POST_NOTIFICATIONS is a runtime permission:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Requested at app startup in MainActivity:

private fun askNotificationPermission() {
if (permissionManager.requiresNotificationPermission() &&
!permissionManager.hasNotificationPermission()
) {
permissionManager.getNotificationPermission()?.let {
requestPermissionLauncher.launch(it)
}
}
}

If the user grants permission, the callback fetches the FCM token:

private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
pushTokenManager.fetchToken(lifecycleScope)
}
}

Notification Settings​

Users control notification delivery at two levels:

User-Level Settings​

data class NotificationSettings(
val id: Int?,
val userId: Int,
val emailEnabled: Boolean = true,
val pushEnabled: Boolean = true,
val callEnabled: Boolean = false,
val defaultPhoneNumber: String? = null,
val events: Map<String, Any>? = null
)

Device-Level Settings​

Per-device overrides:

data class DeviceNotificationSettings(
val id: Int?,
val userId: Int,
val deviceId: Int,
val pushEnabled: Boolean = true,
val emailEnabled: Boolean = false,
val callEnabled: Boolean = false,
val events: Map<String, Any>? = null
)

See data-layer/notifications.md for the full API details.


Design Decisions​

FCM Service Cannot Use Hilt DI​

FCMService extends FirebaseMessagingService and is instantiated by the Firebase SDK, not by Hilt. The NotificationStateRepository is accessed via a static companion on VislaGPSApp:

private val notificationRepository by lazy { VislaGPSApp.notificationRepository }

This is the only place in the app that uses a service locator pattern β€” all other components use constructor injection.

One Channel Per Sound​

Android's NotificationChannel locks the sound after creation β€” it cannot be changed programmatically after the user sees it. The app works around this by creating a new channel for each unique sound key (visla_gps_channel_alert_classic, etc.). This allows the server to assign different sounds to different alarm categories.

Critical Alerts Bypass DND​

SOS and tampering alarms use setBypassDnd(true) with USAGE_ALARM audio attributes. This ensures life-safety alerts are always audible. The channel is separate (visla_critical_alerts) so users can independently control critical vs. normal notification behavior in Android Settings.

Intent Uniqueness via URI​

Each notification uses a unique URI (visla://notification/{id}) as the intent data. Without this, Android reuses the PendingIntent for notifications with the same action, causing the user to navigate to the wrong device. Combined with FLAG_UPDATE_CURRENT, this ensures each notification tap carries the correct extras.

Dual Delivery: Push + WebSocket​

The app uses both FCM and WebSocket for notifications because neither channel is sufficient alone:

  • FCM is needed for background/killed delivery and system tray notifications
  • WebSocket provides sub-second in-app updates without waiting for FCM's Google server round-trip

The NotificationDataStore deduplicates by id, so receiving the same notification via both channels is safe.

Background Notifications Bypass onMessageReceived​

When the app is killed or in the background, FCM notifications with a notification object are handled by the system β€” onMessageReceived() is not called. The in-app list won't update until the user opens the app. The OnScreenVisible intent works around this by fetching from the API whenever the notification screen appears.