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
- ATPManager - Central client for ATP communication
- NotificationReceiver - Handles incoming notifications
- ResponseHandler - Manages sending responses back to the ATP server
Receiving Notifications
There are two main approaches for receiving notifications in Android:1. WebSocket Approach
Copy
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:Copy
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
Copy
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
Copy
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
Copy
/**
* 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:Copy
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
Copy
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:- Receive notifications via WebSocket for real-time updates
- Fall back to polling when WebSocket isn’t available
- Display notifications to users using Android’s notification system
- Respond to notifications directly from notification actions or programmatically
- Store notifications locally for offline access and status tracking
- Handle errors with appropriate retry logic