Skip to main content

Android ATP Client Example

This document provides a simplified example of how to implement the key components of an ATP client for Android, focusing specifically on receiving notifications and responding to them.

Core Components

  1. ATPManager - Central client for ATP communication
  2. NotificationReceiver - Handles incoming notifications
  3. ResponseHandler - Manages sending responses back to the ATP server

Receiving Notifications

There are two main approaches for receiving notifications in Android:

1. WebSocket Approach

class ATPClient(
    private val baseUrl: String,
    private val token: String
) {
    private val httpClient = HttpClient(Android) {
        install(WebSockets)
    }
    
    // Observable notification stream
    private val _notifications = MutableSharedFlow<Notification>()
    val notifications = _notifications.asSharedFlow()
    
    // Observable status updates
    private val _statusUpdates = MutableSharedFlow<StatusUpdate>()
    val statusUpdates = _statusUpdates.asSharedFlow()
    
    /**
     * Start listening for notifications via WebSocket
     */
    fun startWatching() {
        scope.launch {
            try {
                httpClient.webSocket(
                    method = HttpMethod.Get,
                    host = baseUrl.removePrefix("https://"),
                    path = "/api/v1/client/stream?token=$token"
                ) {
                    // Handle incoming WebSocket messages
                    for (frame in incoming) {
                        if (frame is Frame.Text) {
                            val messageText = frame.readText()
                            parseAndEmitNotification(messageText)
                        }
                    }
                }
            } catch (e: Exception) {
                // Handle connection errors and implement reconnection
            }
        }
    }
    
    /**
     * Parse WebSocket message and emit notifications
     */
    private suspend fun parseAndEmitNotification(messageText: String) {
        val jsonObject = Json.parseToJsonElement(messageText).jsonObject
        val type = jsonObject["type"]?.jsonPrimitive?.content
        
        when (type) {
            "notification" -> {
                val notification = Json.decodeFromJsonElement(
                    Notification.serializer(), 
                    jsonObject["data"]!!
                )
                _notifications.emit(notification)
                
                // Store notification for later reference
                notificationStorage.saveNotification(notification)
            }
            "status_update" -> {
                val update = Json.decodeFromJsonElement(
                    StatusUpdate.serializer(), 
                    jsonObject["data"]!!
                )
                _statusUpdates.emit(update)
                
                // Update notification status
                if (update.status == "invalidated" || update.status == "responded") {
                    notificationStorage.updateStatus(update.notification_id, update.status)
                }
            }
        }
    }
}

2. REST Polling Approach

For background polling or when WebSockets aren’t available:
class NotificationPoller(
    private val atpClient: ATPClient
) {
    /**
     * Poll for notifications using WorkManager
     */
    fun schedulePeriodic() {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
            
        val pollingWork = PeriodicWorkRequestBuilder<NotificationWorker>(
            15, TimeUnit.MINUTES
        )
            .setConstraints(constraints)
            .build()
            
        WorkManager.getInstance(context)
            .enqueueUniquePeriodicWork(
                "atp_notification_polling",
                ExistingPeriodicWorkPolicy.REPLACE,
                pollingWork
            )
    }
}

class NotificationWorker(
    context: Context, 
    params: WorkerParameters
) : CoroutineWorker(context, params) {
    
    private val atpClient: ATPClient by inject()
    
    override suspend fun doWork(): Result {
        try {
            // Fetch pending notifications
            val response = atpClient.fetchPendingNotifications(status = "pending")
            
            // Process each notification
            response.notifications.forEach { notification ->
                showNotification(notification)
            }
            
            return Result.success()
        } catch (e: Exception) {
            return if (runAttemptCount < 3) Result.retry() else Result.failure()
        }
    }
    
    private fun showNotification(notification: Notification) {
        // Create notification channel for Android O+
        val channelId = "atp_notifications"
        
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                channelId,
                "ATP Notifications",
                NotificationManager.IMPORTANCE_HIGH
            )
            val notificationManager = applicationContext
                .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
        
        // Create intent for opening notification detail screen
        val intent = Intent(applicationContext, NotificationDetailActivity::class.java).apply {
            putExtra(NOTIFICATION_ID, notification.id)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        }
        
        val pendingIntent = PendingIntent.getActivity(
            applicationContext,
            notification.id.hashCode(),
            intent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
        )
        
        // Build notification
        val builder = NotificationCompat.Builder(applicationContext, channelId)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(notification.context.title)
            .setContentText(notification.context.description)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setContentIntent(pendingIntent)
            .setAutoCancel(true)
        
        // For simple actions, add direct response buttons
        addDirectResponseActions(notification, builder)
        
        // Show notification
        NotificationManagerCompat.from(applicationContext)
            .notify(notification.id.hashCode(), builder.build())
    }
    
    private fun addDirectResponseActions(
        notification: Notification,
        builder: NotificationCompat.Builder
    ) {
        // Only add simple actions as direct buttons
        notification.actions.forEach { action ->
            if (action.response_type == "simple") {
                val responseIntent = Intent(applicationContext, NotificationActionReceiver::class.java).apply {
                    action = ACTION_RESPOND
                    putExtra(NOTIFICATION_ID, notification.id)
                    putExtra(ACTION_ID, action.id)
                }
                
                val pendingIntent = PendingIntent.getBroadcast(
                    applicationContext,
                    action.id.hashCode(),
                    responseIntent,
                    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
                )
                
                builder.addAction(0, action.label, pendingIntent)
            }
        }
    }
}

Responding to Notifications

1. Direct Response from Notification Actions

class NotificationActionReceiver : BroadcastReceiver() {
    
    companion object {
        const val ACTION_RESPOND = "com.example.atp.ACTION_RESPOND"
        const val NOTIFICATION_ID = "notification_id"
        const val ACTION_ID = "action_id"
        const val RESPONSE_DATA = "response_data"
    }
    
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == ACTION_RESPOND) {
            val notificationId = intent.getStringExtra(NOTIFICATION_ID) ?: return
            val actionId = intent.getStringExtra(ACTION_ID) ?: return
            val responseData = intent.getStringExtra(RESPONSE_DATA)
            
            // Parse response data based on type
            val parsedResponseData = when {
                responseData == null -> null
                responseData == "true" -> true
                responseData == "false" -> false
                responseData.toDoubleOrNull() != null -> responseData.toDouble()
                else -> responseData
            }
            
            // Submit response in background
            scope.launch {
                val atpClient: ATPClient = getATPClient(context)
                try {
                    atpClient.respond(notificationId, actionId, parsedResponseData)
                    
                    // Cancel the Android notification
                    NotificationManagerCompat.from(context)
                        .cancel(notificationId.hashCode())
                    
                } catch (e: Exception) {
                    // Handle error
                    Log.e("ATP", "Failed to respond: ${e.message}")
                }
            }
        }
    }
}

2. Submitting Responses Programmatically

class ResponseHandler(private val atpClient: ATPClient) {
    
    /**
     * Submit a response to an ATP notification
     */
    suspend fun respondToNotification(
        notificationId: String,
        actionId: String,
        responseData: Any?
    ): Result<ResponseResult> {
        return try {
            val result = atpClient.respond(notificationId, actionId, responseData)
            
            // Update local storage to mark notification as responded
            NotificationStorage.updateStatus(notificationId, "responded")
            
            // Cancel any related Android system notification
            NotificationManagerCompat.from(context).cancel(notificationId.hashCode())
            
            Result.success(result)
        } catch (e: Exception) {
            // Handle specific error cases
            when {
                // Terminal states - notification is no longer valid
                e.isTerminalState() -> {
                    NotificationStorage.updateStatus(notificationId, "invalidated")
                    NotificationManagerCompat.from(context).cancel(notificationId.hashCode())
                }
                
                // Transient errors - can be retried
                e.isTransient() -> {
                    // Queue for retry with WorkManager
                    enqueueResponseRetry(notificationId, actionId, responseData)
                }
            }
            
            Result.failure(e)
        }
    }
    
    /**
     * Queue response for retry using WorkManager
     */
    private fun enqueueResponseRetry(
        notificationId: String,
        actionId: String,
        responseData: Any?
    ) {
        val data = workDataOf(
            "notification_id" to notificationId,
            "action_id" to actionId,
            "response_data" to responseData?.toString()
        )
        
        val responseWork = OneTimeWorkRequestBuilder<ResponseWorker>()
            .setInputData(data)
            .setBackoffCriteria(
                BackoffPolicy.EXPONENTIAL,
                10, TimeUnit.SECONDS
            )
            .build()
        
        WorkManager.getInstance(context)
            .enqueueUniqueWork(
                "response_$notificationId",
                ExistingWorkPolicy.REPLACE,
                responseWork
            )
    }
}

/**
 * Worker for handling response retries
 */
class ResponseWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {
    
    private val atpClient: ATPClient by inject()
    
    override suspend fun doWork(): Result {
        val notificationId = inputData.getString("notification_id") ?: return Result.failure()
        val actionId = inputData.getString("action_id") ?: return Result.failure()
        val responseDataString = inputData.getString("response_data")
        
        // Parse response data based on type
        val responseData = when {
            responseDataString == null -> null
            responseDataString == "true" -> true
            responseDataString == "false" -> false
            responseDataString.toDoubleOrNull() != null -> responseDataString.toDouble()
            else -> responseDataString
        }
        
        return try {
            atpClient.respond(notificationId, actionId, responseData)
            Result.success()
        } catch (e: Exception) {
            if (e.isTerminalState()) {
                // Don't retry terminal states
                Result.failure()
            } else if (runAttemptCount < 3) {
                // Retry transient errors
                Result.retry()
            } else {
                Result.failure()
            }
        }
    }
}

3. Implementing the Core respond() Method

/**
 * Submit a response to a notification
 */
suspend fun respond(
    notificationId: String,
    actionId: String,
    responseData: Any?
): ResponseResult {
    // Build request body
    val requestBody = buildJsonObject {
        put("notification_id", JsonPrimitive(notificationId))
        put("action_id", JsonPrimitive(actionId))
        
        // Format response_data based on type
        when (responseData) {
            null -> put("response_data", JsonNull)
            is Boolean -> put("response_data", JsonPrimitive(responseData))
            is String -> put("response_data", JsonPrimitive(responseData))
            is Number -> put("response_data", JsonPrimitive(responseData.toDouble()))
            is List<*> -> {
                val array = buildJsonArray {
                    responseData.forEach {
                        when (it) {
                            is String -> add(JsonPrimitive(it))
                            is Number -> add(JsonPrimitive(it.toDouble()))
                            is Boolean -> add(JsonPrimitive(it))
                        }
                    }
                }
                put("response_data", array)
            }
        }
    }
    
    // Send response to ATP server
    val response = httpClient.post("$baseUrl/api/v1/client/respond") {
        header("Authorization", "Bearer $token")
        contentType(ContentType.Application.Json)
        setBody(requestBody.toString())
    }
    
    // Handle error responses
    if (!response.status.isSuccess()) {
        val errorBody = response.bodyAsText()
        val error = json.decodeFromString<ErrorResponse>(errorBody)
        
        throw when (error.code) {
            "NOTIFICATION_EXPIRED", 
            "NOTIFICATION_INVALIDATED", 
            "NOTIFICATION_ALREADY_RESPONDED" -> 
                TerminalStateException(error.code, error.message)
                
            "INVALID_RESPONSE_DATA", 
            "CONSTRAINT_VIOLATION" -> 
                ValidationException(error.code, error.message)
                
            "RATE_LIMIT_EXCEEDED" -> 
                RateLimitException(error.code, error.message)
                
            else -> 
                ATPException(error.code, error.message)
        }
    }
    
    // Parse successful response
    return json.decodeFromString(response.bodyAsText())
}

Notification Storage

A simple approach to store and manage notifications locally:
object NotificationStorage {
    
    private val notifications = mutableMapOf<String, NotificationWithStatus>()
    
    /**
     * Save notification to local storage
     */
    fun saveNotification(notification: Notification) {
        notifications[notification.id] = NotificationWithStatus(
            notification = notification,
            status = notification.status ?: "pending",
            receivedAt = System.currentTimeMillis()
        )
    }
    
    /**
     * Update notification status
     */
    fun updateStatus(notificationId: String, status: String) {
        notifications[notificationId]?.let { notificationWithStatus ->
            notifications[notificationId] = notificationWithStatus.copy(
                status = status,
                updatedAt = System.currentTimeMillis()
            )
        }
    }
    
    /**
     * Get notification by ID
     */
    fun getNotification(notificationId: String): NotificationWithStatus? {
        return notifications[notificationId]
    }
    
    /**
     * Get all notifications with the given status
     */
    fun getNotificationsByStatus(status: String): List<NotificationWithStatus> {
        return notifications.values.filter { it.status == status }
    }
    
    /**
     * Clear all notifications
     */
    fun clearAll() {
        notifications.clear()
    }
    
    /**
     * Clear notifications older than the given timestamp
     */
    fun clearOlderThan(timestamp: Long) {
        notifications.entries.removeIf { it.value.receivedAt < timestamp }
    }
    
    data class NotificationWithStatus(
        val notification: Notification,
        val status: String,
        val receivedAt: Long,
        val updatedAt: Long = receivedAt
    )
}

Setting Up in Your Application

class ATPApplication : Application() {
    
    lateinit var atpClient: ATPClient
    
    override fun onCreate() {
        super.onCreate()
        
        // Setup ATP client
        atpClient = ATPClient(
            baseUrl = "https://atp.example.com",
            token = getUserToken() // Get from your auth system
        )
        
        // Create notification channel
        createNotificationChannel()
        
        // Start WebSocket connection
        atpClient.startWatching()
        
        // Setup background polling as fallback
        val notificationPoller = NotificationPoller(atpClient)
        notificationPoller.schedulePeriodic()
        
        // Collect notifications from client
        CoroutineScope(Dispatchers.Default).launch {
            atpClient.notifications.collect { notification ->
                // Show notification to user
                val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
                val androidNotification = buildAndroidNotification(notification)
                notificationManager.notify(notification.id.hashCode(), androidNotification)
            }
        }
    }
    
    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                "atp_notifications",
                "ATP Notifications",
                NotificationManager.IMPORTANCE_HIGH
            ).apply {
                description = "Notifications requiring human decision"
            }
            
            val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
    }
}

Conclusion

This Android implementation demonstrates the core functionality needed to:
  1. Receive notifications via WebSocket for real-time updates
  2. Fall back to polling when WebSocket isn’t available
  3. Display notifications to users using Android’s notification system
  4. Respond to notifications directly from notification actions or programmatically
  5. Store notifications locally for offline access and status tracking
  6. Handle errors with appropriate retry logic
The implementation focuses on the key components without UI-specific code, providing a foundation that can be integrated with any Android UI framework.
I