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.
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:
| Key | Type | Description |
|---|---|---|
title | String | Notification title (fallback if no notification object) |
body | String | Notification body text |
device_id | String | GPS tracker device ID |
event_type | String | Event type (see NotificationEventType) |
alarm_type | String | Alarm category (sos, tampering, geofence, etc.) |
sound | String | Sound file name (e.g., alert_classic.wav) |
critical | String | "true" for critical alerts |
latitude | String | Device latitude at event time |
longitude | String | Device 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:
- Fetch token from
FirebaseMessaging.getInstance().token - Store in
NotificationStateRepository.fcmToken - 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β
| Trigger | Where | Why |
|---|---|---|
| App startup | MainActivity.onCreate() | pushTokenManager.fetchToken(lifecycleScope) β always fetch, auto-registers if logged in |
| Permission granted | requestPermissionLauncher callback | Fetch token after user grants POST_NOTIFICATIONS |
| Login / verification success | AuthContent composable | onFCMTokenRegister() β registerTokenWithBackend() |
| OAuth login | processIntent() | After OAuthLoginComplete deep link |
| Terms accepted | TermsAcceptanceScreen | After user accepts terms |
| Token refresh | FCMService.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_TOPflag reuses the existing activity (triggersonNewIntent)- Unique URI per notification (
visla://notification/{id}) prevents Android from caching intents FLAG_MUTABLEon 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:
- Foreground β extras from
FCMServicewithEXTRA_FROM_NOTIFICATION = true - 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, ...)
}
}
Navigation Consumptionβ
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 ID | Name | Importance | Behavior |
|---|---|---|---|
visla_gps_channel | Visla GPS Notifications | HIGH | Default sound, vibration |
visla_gps_channel_{soundKey} | Visla GPS ({soundKey}) | HIGH | Custom sound from res/raw/ |
visla_critical_alerts | Allarmi Critici | HIGH | Bypasses 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/:
| Resource | File |
|---|---|
alert_classic | alert_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β
- Server includes
soundfield in push payload (e.g.,"alert_classic.wav") FCMServicestrips the extension:"alert_classic.wav"β"alert_classic"- Channel is selected/created for that sound key
- 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 viaLoadMoreNotifications - 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:
- Push count changes β
notificationStateRepository.notificationCounttriggers a background API refresh - WebSocket events β
notificationDataStore.newNotificationEventinserts directly into the list (deduplicates by ID) - Screen visibility β
OnScreenVisibleintent 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β
| Aspect | FCM Push | WebSocket |
|---|---|---|
| When active | Always (background, killed, foreground) | Only while app is in foreground |
| Latency | Seconds (via Google servers) | Milliseconds (direct connection) |
| System notification | Yes β shows in system tray | No β in-app list only |
| In-app list update | Increments notificationCount β triggers API refresh | Inserts directly into NotificationDataStore |
| Deduplication | N/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.