# Agent L: Notifications + Background Service ## Phase: 4 (Feature Completion) ## Depends on: Agent A (Manifest), Agent I (DI) ## Context The app needs a persistent TCP connection to receive real-time push notifications from the server. On Android, this requires a Foreground Service to keep the connection alive when the app is backgrounded. Additionally, local notifications are shown when messages arrive while the app is not in focus. ## Task Create foreground service for TCP connection, notification channels, and app lifecycle handling. ## Files to Create ### 1. core/ChatService.kt Android Foreground Service: ```kotlin package com.kecalek.chat.core import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import com.kecalek.chat.MainActivity import com.kecalek.chat.R import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class ChatService : Service() { // TODO: @Inject ChatClient or ConnectionManager companion object { const val CHANNEL_ID_SERVICE = "kecalek_service" const val CHANNEL_ID_MESSAGES = "kecalek_messages" const val CHANNEL_ID_GROUPS = "kecalek_groups" const val CHANNEL_ID_SYSTEM = "kecalek_system" const val NOTIFICATION_ID_SERVICE = 1 } override fun onCreate() { super.onCreate() createNotificationChannels() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startForeground(NOTIFICATION_ID_SERVICE, buildServiceNotification()) // TODO: Start TCP connection listener // TODO: Handle incoming notifications and show local notifications return START_STICKY } override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { // TODO: Disconnect from server super.onDestroy() } private fun createNotificationChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = getSystemService(NotificationManager::class.java) // Service channel (silent, low priority) manager.createNotificationChannel( NotificationChannel( CHANNEL_ID_SERVICE, "Connection Service", NotificationManager.IMPORTANCE_LOW, ).apply { description = "Keeps the encrypted connection alive" setShowBadge(false) } ) // Message notifications (high priority, sound) manager.createNotificationChannel( NotificationChannel( CHANNEL_ID_MESSAGES, "Messages", NotificationManager.IMPORTANCE_HIGH, ).apply { description = "New message notifications" enableVibration(true) } ) // Group notifications manager.createNotificationChannel( NotificationChannel( CHANNEL_ID_GROUPS, "Groups", NotificationManager.IMPORTANCE_DEFAULT, ).apply { description = "Group activity notifications" } ) // System notifications (key changes, connection) manager.createNotificationChannel( NotificationChannel( CHANNEL_ID_SYSTEM, "System", NotificationManager.IMPORTANCE_LOW, ).apply { description = "Connection and security notifications" } ) } } private fun buildServiceNotification(): Notification { val intent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) return NotificationCompat.Builder(this, CHANNEL_ID_SERVICE) .setContentTitle("Kecalek") .setContentText("Connected securely") .setSmallIcon(android.R.drawable.ic_lock_lock) // TODO: Custom icon .setContentIntent(pendingIntent) .setOngoing(true) .setSilent(true) .build() } } ``` ### 2. core/NotificationHelper.kt Helper for showing local notifications: ```kotlin package com.kecalek.chat.core import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import com.kecalek.chat.MainActivity import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @Singleton class NotificationHelper @Inject constructor( @ApplicationContext private val context: Context, ) { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private var nextNotificationId = 100 fun showMessageNotification( senderName: String, conversationId: String, messagePreview: String?, ) { // E2EE: Never show plaintext in notification if app is locked val displayText = messagePreview ?: "New encrypted message" val intent = Intent(context, MainActivity::class.java).apply { putExtra("conversationId", conversationId) flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } val pendingIntent = PendingIntent.getActivity( context, conversationId.hashCode(), intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_MESSAGES) .setContentTitle(senderName) .setContentText(displayText) .setSmallIcon(android.R.drawable.ic_dialog_email) // TODO: Custom icon .setContentIntent(pendingIntent) .setAutoCancel(true) .setGroup("messages") .build() notificationManager.notify(nextNotificationId++, notification) } fun showGroupNotification( groupName: String, action: String, // "invited you", "member joined", etc. ) { val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_GROUPS) .setContentTitle(groupName) .setContentText(action) .setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Custom icon .setAutoCancel(true) .build() notificationManager.notify(nextNotificationId++, notification) } fun showSystemNotification(title: String, text: String) { val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_SYSTEM) .setContentTitle(title) .setContentText(text) .setSmallIcon(android.R.drawable.ic_lock_lock) // TODO: Custom icon .setAutoCancel(true) .build() notificationManager.notify(nextNotificationId++, notification) } fun cancelAll() { notificationManager.cancelAll() } } ``` ### 3. core/AppLifecycleObserver.kt ```kotlin package com.kecalek.chat.core import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import javax.inject.Inject import javax.inject.Singleton /** * Observes app lifecycle to manage TCP connection state. * - Foreground: ensure connection is active * - Background: keep connection via foreground service */ @Singleton class AppLifecycleObserver @Inject constructor( // TODO: Inject ChatClient or ConnectionManager ) : DefaultLifecycleObserver { var isInForeground: Boolean = false private set override fun onStart(owner: LifecycleOwner) { isInForeground = true // TODO: Reconnect if disconnected, health check } override fun onStop(owner: LifecycleOwner) { isInForeground = false // TODO: Connection stays alive via foreground service } } ``` ### 4. Update AndroidManifest.xml Add service declaration: ```xml ``` ## Notification Types to Handle These 17 server push notification types must trigger appropriate local notifications: | Server Type | Notification Channel | Display | |-------------|---------------------|---------| | `new_message` | Messages | "Sender: message preview" | | `messages_read` | (none) | Silent — update UI only | | `message_deleted` | (none) | Silent — update UI only | | `message_reacted` | (none) | Silent — update UI only | | `message_pinned` | (none) | Silent — update UI only | | `message_unpinned` | (none) | Silent — update UI only | | `conversation_created` | Groups | "New conversation created" | | `conversation_renamed` | Groups | "Group renamed to X" | | `member_added` | Groups | "X joined the group" | | `member_removed` | Groups | "X was removed" | | `group_invitation` | Groups | "X invited you to Y" | | `user_online` | (none) | Silent — update UI only | | `user_offline` | (none) | Silent — update UI only | | `online_users` | (none) | Silent — update UI only | | `session_reset` | System | "Session reset by X" | | `keys_updated` | System | "Security keys updated" | ## Constraints - Foreground service with `FOREGROUND_SERVICE_DATA_SYNC` type - `START_STICKY` to restart if killed - Notification channels required for Android O+ - E2EE consideration: don't show plaintext in notifications when app is locked - Group notifications by conversation for notification stacking - Use PendingIntent.FLAG_IMMUTABLE (Android 12+ requirement) ## DO NOT - Implement actual TCP connection management - Handle message decryption - Implement Firebase Cloud Messaging (future enhancement) - Store messages or update database directly (delegate to ChatClient)