Skip to main content

Integrating ATP into a Modern Android App

This guide demonstrates how to integrate an existing ATPClient into a modern Android application using dependency injection (Hilt) and following architectural best practices.

Dependency Injection Setup

First, set up the ATP module in your Hilt configuration:
@Module
@InstallIn(SingletonComponent::class)
object ATPModule {
    
    @Provides
    @Singleton
    fun provideATPClient(
        @ApplicationContext context: Context,
        authManager: AuthManager
    ): ATPClient {
        return ATPClient(
            baseUrl = BuildConfig.ATP_BASE_URL,
            token = authManager.getUserToken(),
            context = context
        )
    }
    
    @Provides
    @Singleton
    fun provideNotificationManager(
        atpClient: ATPClient,
        @ApplicationContext context: Context
    ): ATPNotificationManager {
        return ATPNotificationManager(atpClient, context)
    }
    
    @Provides
    @Singleton
    fun provideResponseHandler(
        atpClient: ATPClient,
        workManager: WorkManager
    ): ATPResponseHandler {
        return ATPResponseHandler(atpClient, workManager)
    }
}

Notification Manager

Create a manager class to handle the notification lifecycle:
@Singleton
class ATPNotificationManager @Inject constructor(
    private val atpClient: ATPClient,
    private val context: Context
) {
    private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) 
        as NotificationManager
    
    init {
        createNotificationChannel()
        
        // Collect notifications from ATP client
        CoroutineScope(Dispatchers.Default).launch {
            atpClient.notifications.collect { notification ->
                showNotification(notification)
            }
        }
        
        // Collect status updates
        CoroutineScope(Dispatchers.Default).launch {
            atpClient.statusUpdates.collect { update ->
                if (update.status == "invalidated" || update.status == "responded") {
                    cancelNotification(update.notification_id)
                }
            }
        }
    }
    
    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                "ATP Notifications",
                NotificationManager.IMPORTANCE_HIGH
            ).apply {
                description = "Notifications requiring human decision"
            }
            notificationManager.createNotificationChannel(channel)
        }
    }
    
    private fun showNotification(notification: Notification) {
        // Create intent for notification tap action
        val tapIntent = Intent(context, NotificationActivity::class.java).apply {
            putExtra(EXTRA_NOTIFICATION_ID, notification.id)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        }
        
        val tapPendingIntent = PendingIntent.getActivity(
            context, 
            notification.id.hashCode(),
            tapIntent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
        )
        
        // Build notification
        val builder = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(notification.context.title)
            .setContentText(notification.context.description)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setContentIntent(tapPendingIntent)
            .setAutoCancel(true)
        
        // Add action buttons for simple responses
        notification.actions.forEach { action ->
            if (action.response_type == "simple") {
                val actionIntent = Intent(context, NotificationActionReceiver::class.java).apply {
                    this.action = ACTION_RESPOND
                    putExtra(EXTRA_NOTIFICATION_ID, notification.id)
                    putExtra(EXTRA_ACTION_ID, action.id)
                }
                
                val actionPendingIntent = PendingIntent.getBroadcast(
                    context,
                    action.id.hashCode(),
                    actionIntent,
                    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
                )
                
                builder.addAction(0, action.label, actionPendingIntent)
            }
        }
        
        // Show notification
        notificationManager.notify(notification.id.hashCode(), builder.build())
    }
    
    fun cancelNotification(notificationId: String) {
        notificationManager.cancel(notificationId.hashCode())
    }
    
    companion object {
        const val CHANNEL_ID = "atp_notifications"
        const val ACTION_RESPOND = "com.example.app.ACTION_RESPOND"
        const val EXTRA_NOTIFICATION_ID = "notification_id"
        const val EXTRA_ACTION_ID = "action_id"
    }
}

Response Handler

Create a handler for responses with retry support:
@Singleton
class ATPResponseHandler @Inject constructor(
    private val atpClient: ATPClient,
    private val workManager: WorkManager
) {
    /**
     * Submit a response to a notification
     */
    suspend fun respondToNotification(
        notificationId: String,
        actionId: String,
        responseData: Any?
    ): Result<ResponseResult> {
        return try {
            val result = atpClient.respond(notificationId, actionId, responseData)
            Result.success(result)
        } catch (e: Exception) {
            if (isTerminalError(e)) {
                // Don't retry terminal errors
                Result.failure(e)
            } else {
                // Queue for retry
                enqueueResponseRetry(notificationId, actionId, responseData)
                Result.failure(e)
            }
        }
    }
    
    /**
     * Queue a response for retry using WorkManager
     */
    private fun enqueueResponseRetry(
        notificationId: String,
        actionId: String,
        responseData: Any?
    ) {
        val data = workDataOf(
            KEY_NOTIFICATION_ID to notificationId,
            KEY_ACTION_ID to actionId,
            KEY_RESPONSE_DATA to responseData?.toString()
        )
        
        val responseWork = OneTimeWorkRequestBuilder<ResponseWorker>()
            .setInputData(data)
            .setBackoffCriteria(
                BackoffPolicy.EXPONENTIAL,
                10, TimeUnit.SECONDS
            )
            .build()
        
        workManager.enqueueUniqueWork(
            "response_$notificationId",
            ExistingWorkPolicy.REPLACE,
            responseWork
        )
    }
    
    private fun isTerminalError(error: Exception): Boolean {
        val message = error.message?.lowercase() ?: ""
        return message.contains("already_responded") ||
               message.contains("expired") ||
               message.contains("invalidated") ||
               message.contains("invalid_response_data")
    }
    
    companion object {
        const val KEY_NOTIFICATION_ID = "notification_id"
        const val KEY_ACTION_ID = "action_id"
        const val KEY_RESPONSE_DATA = "response_data"
    }
}

Response Worker

Create a WorkManager worker for handling response retries:
class ResponseWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {
    
    @Inject
    lateinit var atpClient: ATPClient
    
    override suspend fun doWork(): Result {
        val notificationId = inputData.getString(ATPResponseHandler.KEY_NOTIFICATION_ID) ?: return Result.failure()
        val actionId = inputData.getString(ATPResponseHandler.KEY_ACTION_ID) ?: return Result.failure()
        val responseDataString = inputData.getString(ATPResponseHandler.KEY_RESPONSE_DATA)
        
        // Parse response data based on type
        val responseData = parseResponseData(responseDataString)
        
        return try {
            atpClient.respond(notificationId, actionId, responseData)
            Result.success()
        } catch (e: Exception) {
            // Check if we should retry
            val isTerminal = e.message?.lowercase()?.let { message ->
                message.contains("already_responded") ||
                message.contains("expired") ||
                message.contains("invalidated") ||
                message.contains("invalid_response_data")
            } ?: false
            
            if (isTerminal) {
                Result.failure()
            } else if (runAttemptCount < 3) {
                Result.retry()
            } else {
                Result.failure()
            }
        }
    }
    
    private fun parseResponseData(responseDataString: String?): Any? {
        return when {
            responseDataString == null -> null
            responseDataString == "true" -> true
            responseDataString == "false" -> false
            responseDataString.toDoubleOrNull() != null -> responseDataString.toDouble()
            else -> responseDataString
        }
    }
}

BroadcastReceiver for Notification Actions

Create a receiver for handling direct notification responses:
class NotificationActionReceiver : BroadcastReceiver() {
    
    @Inject
    lateinit var responseHandler: ATPResponseHandler
    
    @Inject
    lateinit var notificationManager: ATPNotificationManager
    
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == ATPNotificationManager.ACTION_RESPOND) {
            val notificationId = intent.getStringExtra(ATPNotificationManager.EXTRA_NOTIFICATION_ID) ?: return
            val actionId = intent.getStringExtra(ATPNotificationManager.EXTRA_ACTION_ID) ?: return
            
            // Respond in the background
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    responseHandler.respondToNotification(notificationId, actionId, null)
                    
                    // Cancel the notification
                    withContext(Dispatchers.Main) {
                        notificationManager.cancelNotification(notificationId)
                    }
                } catch (e: Exception) {
                    Log.e("ATP", "Failed to respond: ${e.message}")
                }
            }
        }
    }
}

ViewModel for Notification Handling

Create a ViewModel to interact with notifications in your UI:
@HiltViewModel
class NotificationViewModel @Inject constructor(
    private val atpClient: ATPClient,
    private val responseHandler: ATPResponseHandler
) : ViewModel() {
    
    // Notification storage
    private val _notifications = MutableStateFlow<List<Notification>>(emptyList())
    val notifications: StateFlow<List<Notification>> = _notifications.asStateFlow()
    
    // Loading state
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    
    // Response status
    private val _responseStatus = MutableStateFlow<ResponseStatus?>(null)
    val responseStatus: StateFlow<ResponseStatus?> = _responseStatus.asStateFlow()
    
    init {
        // Collect notifications from ATP client
        viewModelScope.launch {
            atpClient.notifications.collect { notification ->
                _notifications.update { currentList ->
                    currentList + notification
                }
            }
        }
        
        // Collect status updates
        viewModelScope.launch {
            atpClient.statusUpdates.collect { update ->
                if (update.status == "invalidated" || update.status == "responded") {
                    _notifications.update { currentList ->
                        currentList.filter { it.id != update.notification_id }
                    }
                }
            }
        }
        
        // Load initial notifications
        loadNotifications()
    }
    
    /**
     * Load existing notifications
     */
    fun loadNotifications() {
        viewModelScope.launch {
            _isLoading.value = true
            
            try {
                val response = atpClient.fetchPendingNotifications()
                _notifications.value = response.notifications
            } catch (e: Exception) {
                Log.e("ATP", "Failed to load notifications: ${e.message}")
            } finally {
                _isLoading.value = false
            }
        }
    }
    
    /**
     * Respond to a notification
     */
    fun respondToNotification(
        notificationId: String,
        actionId: String,
        responseData: Any?
    ) {
        viewModelScope.launch {
            _isLoading.value = true
            
            try {
                val result = responseHandler.respondToNotification(
                    notificationId,
                    actionId,
                    responseData
                )
                
                result.onSuccess {
                    _responseStatus.value = ResponseStatus.Success
                    
                    // Remove notification from the list
                    _notifications.update { currentList ->
                        currentList.filter { it.id != notificationId }
                    }
                }.onFailure { error ->
                    _responseStatus.value = ResponseStatus.Error(error.message ?: "Unknown error")
                }
            } finally {
                _isLoading.value = false
            }
        }
    }
    
    /**
     * Clear response status
     */
    fun clearResponseStatus() {
        _responseStatus.value = null
    }
    
    /**
     * Response status sealed class
     */
    sealed class ResponseStatus {
        object Success : ResponseStatus()
        data class Error(val message: String) : ResponseStatus()
    }
}

Application Setup

In your Application class, initialize ATP:
@HiltAndroidApp
class MyApplication : Application() {
    
    @Inject
    lateinit var atpClient: ATPClient
    
    override fun onCreate() {
        super.onCreate()
        
        // Start watching for notifications
        atpClient.startWatching()
    }
}

Ensuring Proper Background Processing

Configure a reliable fallback for environments where WebSockets might not be reliable:
@HiltWorker
class NotificationPollingWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val atpClient: ATPClient,
    private val notificationManager: ATPNotificationManager
) : CoroutineWorker(context, params) {
    
    override suspend fun doWork(): Result {
        try {
            // Fetch pending notifications
            val response = atpClient.fetchPendingNotifications()
            
            // Process each notification
            response.notifications.forEach { notification ->
                // Show system notification (handled by notificationManager through the flow)
            }
            
            return Result.success()
        } catch (e: Exception) {
            return if (runAttemptCount < 3) Result.retry() else Result.failure()
        }
    }
    
    companion object {
        /**
         * Schedule periodic polling
         */
        fun schedule(workManager: WorkManager) {
            val constraints = Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .build()
            
            val pollingWork = PeriodicWorkRequestBuilder<NotificationPollingWorker>(
                15, TimeUnit.MINUTES,
                5, TimeUnit.MINUTES
            )
                .setConstraints(constraints)
                .build()
            
            workManager.enqueueUniquePeriodicWork(
                "atp_notification_polling",
                ExistingWorkPolicy.REPLACE,
                pollingWork
            )
        }
    }
}
Initialize this in your Application class:
@HiltAndroidApp
class MyApplication : Application() {
    
    @Inject
    lateinit var atpClient: ATPClient
    
    @Inject
    lateinit var workManager: WorkManager
    
    override fun onCreate() {
        super.onCreate()
        
        // Start watching for notifications
        atpClient.startWatching()
        
        // Schedule fallback polling
        NotificationPollingWorker.schedule(workManager)
    }
}

Conclusion

This implementation demonstrates how to integrate an existing ATPClient into a modern Android application using:
  1. Hilt for dependency injection
  2. WorkManager for background processing and retry logic
  3. ViewModel for connecting ATP with the UI layer
  4. Coroutines and Flow for reactive data handling
  5. BroadcastReceiver for notification actions
The implementation follows the recommended architecture patterns:
  • Separation of concerns - Each component has a clear responsibility
  • Single source of truth - ViewModel manages notification state
  • Reactive approach - Changes to notifications are observed via Flow
  • Robust error handling - Includes retry logic for transient failures
  • Dependency injection - Components are provided via Hilt
  • Background processing - Uses WorkManager for reliable background tasks
This approach ensures that your application will receive and respond to ATP notifications reliably, even when the app is in the background.
I